From 336e7d1b89012b49572fdaa85dd69c6e86e00e3c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 3 Nov 2026 10:28:26 +0700 Subject: [PATCH] Add first-run setup that walks new users through Paseo (#8363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): add first-run setup modal New users landed on an empty "Add a project" screen and never found providers, Paseo tools or phone pairing, which all live in Settings. A one-time setup modal now walks them through Appearance, Providers, Projects, Orchestration and Phone. Every step renders the same component Settings uses, so changing something in setup changes it in Settings too. The modal opens only when the setup host is connected, its workspaces have loaded and it has no projects. Existing users already have projects, so they never see it after upgrading and no migration is needed. Finishing or skipping writes `@paseo:onboarding` on this client. It uses its own key because it records feature state, and e2e specs replace app settings wholesale. Settings > General > "Show setup again" reopens the modal on step 1. Native phones skip the modal for now. Shared pieces moved out so setup and Settings render the same thing: - ThemePicker is a tile grid and replaces the Settings theme dropdown. - PaseoToolsCard is extracted from the host page. - The skeleton pulse moves to components/ui. Provider rows show it while the first snapshot loads instead of "Loading…". - The pair-device direct-connection hint links to the Tailscale docs. The e2e fixture marks setup completed by default so existing specs keep their starting state. The desktop smoke and lifecycle scripts, which start from a fresh daemon, mark it before they continue. * feat(onboarding): suggest projects from agent history The setup's Projects step lists the repos the user already ran agents in, with busy recent ones preselected, so a new user adds their projects in one click instead of browsing for folders. With rows checked, the footer's main button adds them and only moves on once every one was added; a checked row always means "this will be added". The daemon resolves each session folder to its project root (main checkout for worktrees, repo root for subfolders) because only it can read the git state; the app asks once per provider so a slow or broken provider never holds up the others and can be retried on its own. Hosts without the projectSuggestions feature show only the Add project button. Onboarding e2e hosts read an empty Claude history with other providers off, so suggestions never depend on the machine running the tests. * fix(onboarding): keep late project adds on their step and show Retry early - A finished add only advances setup while the Projects step is still open. - A failed provider shows its Retry as soon as any provider answers. - The desktop lifecycle check no longer reloads the window: setup never opens there because no host is connected. * fix(onboarding): bind late project adds to their step visit and freeze rows while adding - Each step visit has an id; a finished add only advances the visit that started it, so leaving and returning to Projects, or reopening setup, ignores it. - Suggestion rows are disabled while their batch is being added. * test(app): drive theme selection through the tile picker Settings → Appearance no longer has a theme dropdown. The Escape test opens the syntax theme dropdown instead, and the Android system-bars check taps theme tiles. * fix(server): return project suggestion roots in the form project.add stores Git reports Windows repo roots with forward slashes, so an added suggestion kept showing because its path never matched the new project's root. * feat(app): two control sizes, orchestration switches and mobile connect choices in setup Controls had four sizes picked per call site, so a button in a dialog, a settings row and a setup step came out at different heights. Controls now have two: md (default; 32px with a pointer, 44px in the compact layout so phone controls are full touch targets) and sm (28px, for tight chrome and nested settings rows). The breakpoint picks md's height, not the caller. Setup: - Orchestration is three switches (Paseo tools, agent skills, CLI), all on, applied on Continue. Turning one off undoes it; the desktop CLI uninstall removes only the file this app installed. - The Mobile step has store buttons and a "Choose how to connect" section: Relay opens the existing pairing modal, Tailscale opens the docs. Settings section docs links are one pattern, SettingsSection's `link` prop, and modals sit on surface0 like a screen. The vitest unistyles stub now resolves breakpoint values by window width, as Unistyles does at runtime; without it md controls had no height in browser tests. * fix(app): finish setup on its last step and confirm undoing orchestration In the compact layout Orchestration is the last step, and Finish closed setup without applying its switches. The last step's work now runs before Finish, the same as Continue. Turning off skills now asks first, as Settings does. Removing the CLI keeps a paseo command this app didn't install and says so. A failed Paseo tools update shows a toast. Adding suggested projects no longer advances when a row was checked while the add ran. Chat sender attribution and the message preview toggle go back to the sm button: md padding is a breakpoint value, and on web its media-query rule beat the zero right padding that aligns the sender with its bubble. * fix(app): ignore a late Finish once the user left the last setup step Finish called complete(), which ignored the step visit, so pressing Back while Orchestration applied its choices still closed setup and recorded it as done. next(visit) now finishes setup from the last step, so a late Finish is dropped the same way a late Continue already was. * fix(app): hold the skills switch while setup applies its choices The Paseo tools and CLI switches were disabled while Continue or Finish applied them, but the skills switch only locked while a skills operation ran. With skills needing no change, it stayed editable during a pending tools update and the new value was silently dropped. * fix(app): read setup's skills choice as off when no skill is picked Saving an empty selection in the skills picker left setup's skills switch on. Continue then asked the host to install nothing, the host reported skills as not installed, and the step stayed put with no error. Setup now reads skills as off with nothing picked, as Settings does. --- docs/design.md | 18 +- .../browser/appearance-theme-picker.spec.ts | 8 +- packages/app/e2e/browser/onboarding.spec.ts | 311 ++++++++++++++ packages/app/e2e/browser/plugin-theme.spec.ts | 37 +- .../e2e/browser/settings-navigation.spec.ts | 6 +- .../app/e2e/mobile/system-bars/android.mjs | 5 +- packages/app/e2e/support/fixtures.ts | 15 +- .../app/e2e/support/helpers/agent-skills.ts | 14 +- .../support/helpers/daemon-websocket-gate.ts | 11 +- .../support/helpers/isolated-host-daemon.ts | 45 +- .../app/e2e/support/helpers/onboarding.ts | 230 ++++++++++ .../agent-profile-appearance-field.tsx | 4 +- .../settings/agent-profile-edit-modal.tsx | 7 +- .../settings/agent-profile-row.tsx | 4 - .../settings/agent-profiles-section.tsx | 3 +- packages/app/src/agent-skills/index.tsx | 298 ++++++------- .../app/src/agent-skills/selection-sheet.tsx | 112 ++--- .../app/src/agent-skills/use-agent-skills.ts | 31 +- .../src/agent-stream/chat-find/index.web.tsx | 2 +- packages/app/src/app/_layout.tsx | 2 + packages/app/src/appearance/theme-picker.tsx | 292 +++++++++++++ .../changelog/internal/changelog-sheet.tsx | 2 +- .../src/components/adaptive-modal-sheet.tsx | 46 +- .../src/components/archived-agent-callout.tsx | 1 - .../app/src/components/community-links.tsx | 6 +- .../app/src/components/icons/apple-icon.tsx | 9 + .../src/components/icons/google-play-icon.tsx | 19 + .../src/components/import-session-sheet.tsx | 4 +- packages/app/src/components/model-browser.tsx | 2 +- .../app/src/components/project-edit-sheet.tsx | 7 +- .../src/components/provider-catalog-list.tsx | 2 +- .../components/provider-diagnostic-sheet.tsx | 16 +- packages/app/src/components/rename-modal.tsx | 2 - .../components/schedules/cadence-editor.tsx | 4 +- .../schedules/schedule-form-sheet.tsx | 13 +- .../src/components/settings/detail-row.tsx | 25 +- .../settings/headings/settings-section.tsx | 17 + .../src/components/settings/row-density.tsx | 14 +- .../src/components/settings/row-header.tsx | 56 ++- .../sidebar-agent-list-skeleton.tsx | 38 +- .../src/components/sidebar/empty-states.tsx | 6 +- packages/app/src/components/ui/alert.tsx | 8 +- packages/app/src/components/ui/button.tsx | 43 +- .../components/ui/control-geometry.test.ts | 128 +++--- .../app/src/components/ui/control-geometry.ts | 231 +++++----- .../app/src/components/ui/control-size.tsx | 10 +- .../src/components/ui/dropdown-trigger.tsx | 31 +- .../app/src/components/ui/external-link.tsx | 31 +- packages/app/src/components/ui/form-field.tsx | 19 +- .../src/components/ui/segmented-control.tsx | 18 +- .../app/src/components/ui/select-field.tsx | 6 +- packages/app/src/components/ui/skeleton.tsx | 34 ++ .../app/src/components/ui/switch.test.tsx | 1 + packages/app/src/components/ui/switch.tsx | 65 ++- .../app/src/components/welcome-screen.tsx | 1 - .../app/src/composer/input/presentation.tsx | 4 +- .../desktop/browser/pane/index.electron.tsx | 4 +- .../browser/settings/browser-data-section.tsx | 1 - .../desktop-notifications-section.tsx | 2 - .../components/desktop-permission-row.tsx | 2 +- .../desktop-permissions-section.tsx | 1 - .../components/desktop-updates-section.tsx | 48 +-- .../components/integrations-section.tsx | 120 +++--- .../components/pair-device-section.tsx | 26 +- .../app/src/desktop/daemon/desktop-daemon.ts | 4 + .../src/desktop/hooks/use-install-status.ts | 63 ++- packages/app/src/file-pane/pane.tsx | 2 +- .../src/file-panels/source/conflict-alert.tsx | 4 +- packages/app/src/file-panels/source/panel.tsx | 2 +- .../app/src/file-panels/source/toolbar.tsx | 2 +- packages/app/src/git/actions-split-button.tsx | 12 +- packages/app/src/git/diff-pane.tsx | 2 +- .../pull-request-panel/activity-skeleton.tsx | 34 +- .../git/pull-request-panel/checks-section.tsx | 2 +- .../src/git/pull-request-panel/pane-error.tsx | 2 +- .../git/pull-request-panel/pane-skeleton.tsx | 3 +- .../app/src/git/pull-request-panel/pane.tsx | 12 +- .../git/pull-request-panel/section-kit.tsx | 4 +- .../internal/add-remote-ssh-host-modal.tsx | 24 +- .../app/src/hosts/host-confirmation-sheet.tsx | 2 - packages/app/src/i18n/resources/ar.ts | 76 +++- packages/app/src/i18n/resources/en.ts | 77 +++- packages/app/src/i18n/resources/es.ts | 78 +++- packages/app/src/i18n/resources/fr.ts | 79 +++- packages/app/src/i18n/resources/ja.ts | 79 +++- packages/app/src/i18n/resources/ko.ts | 77 +++- packages/app/src/i18n/resources/pt-BR.ts | 77 +++- packages/app/src/i18n/resources/ru.ts | 78 +++- packages/app/src/i18n/resources/zh-CN.ts | 75 +++- .../agent-route-resolution-view.tsx | 8 +- packages/app/src/onboarding/index.ts | 9 + .../onboarding/internal/completion-storage.ts | 25 ++ .../app/src/onboarding/internal/model.test.ts | 220 ++++++++++ packages/app/src/onboarding/internal/model.ts | 170 ++++++++ .../internal/onboarding-instance.ts | 10 + .../onboarding/internal/onboarding-modal.tsx | 394 ++++++++++++++++++ .../internal/orchestration-setup.ts | 113 +++++ .../internal/steps/appearance-step.tsx | 5 + .../src/onboarding/internal/steps/index.tsx | 33 ++ .../internal/steps/orchestration-step.tsx | 30 ++ .../onboarding/internal/steps/phone-step.tsx | 105 +++++ .../internal/steps/projects-step.tsx | 109 +++++ .../internal/steps/providers-step.tsx | 10 + .../src/onboarding/internal/use-setup-host.ts | 14 + .../app/src/onboarding/onboarding-host.tsx | 40 ++ .../app/src/onboarding/use-open-onboarding.ts | 15 + packages/app/src/pane-find/index.tsx | 6 +- packages/app/src/panels/agent-panel.tsx | 3 +- packages/app/src/panels/setup-panel.tsx | 2 +- packages/app/src/plugins/settings/index.tsx | 3 +- packages/app/src/project-suggestions/index.ts | 10 + .../internal/folder-project-icon.tsx | 39 ++ .../internal/project-suggestions.tsx | 207 +++++++++ .../internal/suggestions.test.ts | 143 +++++++ .../internal/suggestions.ts | 90 ++++ .../internal/use-project-suggestions.ts | 112 +++++ .../internal/use-provider-suggestions.ts | 76 ++++ packages/app/src/projects/icons.ts | 4 +- packages/app/src/review/comment-sheet.tsx | 3 - packages/app/src/review/feedback/surface.tsx | 5 +- .../app/src/review/feedback/use-feedback.ts | 6 +- packages/app/src/review/surface.tsx | 4 +- .../new-workspace/form/field-trigger.tsx | 2 +- .../new-workspace/import-session-button.tsx | 1 - .../src/screens/project-settings-screen.tsx | 63 +-- packages/app/src/screens/schedules-screen.tsx | 9 +- packages/app/src/screens/settings-screen.tsx | 26 +- .../appearance/appearance-section.tsx | 224 +--------- .../src/screens/settings/files-section.tsx | 1 - .../app/src/screens/settings/host-page.tsx | 65 +-- .../settings/keyboard-shortcuts-section.tsx | 6 +- .../settings/metadata-generation-page.tsx | 11 +- .../src/screens/settings/paseo-tools-card.tsx | 56 +++ .../app/src/screens/settings/plugins-page.tsx | 6 +- .../settings/providers-section.test.tsx | 5 + .../screens/settings/providers-section.tsx | 85 +++- .../settings/restart-recovery-card.tsx | 4 +- .../settings/sidebar/sidebar-nav-section.tsx | 2 - .../settings/terminal/terminal-section.tsx | 1 - .../settings/voice/speech-provider-sheet.tsx | 10 +- .../scripts/workspace-scripts-button.tsx | 4 +- .../workspace/workspace-desktop-tabs-row.tsx | 8 +- .../workspace/workspace-route-state-views.tsx | 11 +- .../src/stores/session-store-hooks/index.ts | 9 + .../stores/session-store-hooks/selectors.ts | 8 + packages/app/src/styles/settings.ts | 4 - packages/app/src/styles/theme.ts | 4 +- packages/app/src/subagents/parent/callout.tsx | 1 - packages/app/src/usage/options.tsx | 1 - packages/app/src/usage/usage-section.tsx | 2 +- .../src/workspace-labels/manager-modal.tsx | 6 +- .../src/workspace/open-in-editor/button.tsx | 8 +- .../app/test-stubs/react-native-unistyles.ts | 47 ++- packages/client/src/daemon-client.ts | 21 + .../e2e/appearance-font-size.electron.mjs | 3 +- packages/desktop/e2e/packaged-app-smoke.js | 5 + packages/desktop/src/daemon/daemon-manager.ts | 2 + .../src/integrations/cli-install/index.ts | 2 +- .../src/integrations/cli-install/install.ts | 82 ++-- packages/protocol/src/messages.ts | 34 ++ .../src/server/agent/import-sessions.ts | 2 +- .../authorization/operation-permissions.ts | 2 + .../src/server/project-suggestions.test.ts | 188 +++++++++ .../server/src/server/project-suggestions.ts | 177 ++++++++ packages/server/src/server/session.ts | 34 ++ .../server/src/server/websocket-server.ts | 2 + 166 files changed, 5190 insertions(+), 1525 deletions(-) create mode 100644 packages/app/e2e/browser/onboarding.spec.ts create mode 100644 packages/app/e2e/support/helpers/onboarding.ts create mode 100644 packages/app/src/appearance/theme-picker.tsx create mode 100644 packages/app/src/components/icons/apple-icon.tsx create mode 100644 packages/app/src/components/icons/google-play-icon.tsx create mode 100644 packages/app/src/components/ui/skeleton.tsx create mode 100644 packages/app/src/onboarding/index.ts create mode 100644 packages/app/src/onboarding/internal/completion-storage.ts create mode 100644 packages/app/src/onboarding/internal/model.test.ts create mode 100644 packages/app/src/onboarding/internal/model.ts create mode 100644 packages/app/src/onboarding/internal/onboarding-instance.ts create mode 100644 packages/app/src/onboarding/internal/onboarding-modal.tsx create mode 100644 packages/app/src/onboarding/internal/orchestration-setup.ts create mode 100644 packages/app/src/onboarding/internal/steps/appearance-step.tsx create mode 100644 packages/app/src/onboarding/internal/steps/index.tsx create mode 100644 packages/app/src/onboarding/internal/steps/orchestration-step.tsx create mode 100644 packages/app/src/onboarding/internal/steps/phone-step.tsx create mode 100644 packages/app/src/onboarding/internal/steps/projects-step.tsx create mode 100644 packages/app/src/onboarding/internal/steps/providers-step.tsx create mode 100644 packages/app/src/onboarding/internal/use-setup-host.ts create mode 100644 packages/app/src/onboarding/onboarding-host.tsx create mode 100644 packages/app/src/onboarding/use-open-onboarding.ts create mode 100644 packages/app/src/project-suggestions/index.ts create mode 100644 packages/app/src/project-suggestions/internal/folder-project-icon.tsx create mode 100644 packages/app/src/project-suggestions/internal/project-suggestions.tsx create mode 100644 packages/app/src/project-suggestions/internal/suggestions.test.ts create mode 100644 packages/app/src/project-suggestions/internal/suggestions.ts create mode 100644 packages/app/src/project-suggestions/internal/use-project-suggestions.ts create mode 100644 packages/app/src/project-suggestions/internal/use-provider-suggestions.ts create mode 100644 packages/app/src/screens/settings/paseo-tools-card.tsx create mode 100644 packages/server/src/server/project-suggestions.test.ts create mode 100644 packages/server/src/server/project-suggestions.ts diff --git a/docs/design.md b/docs/design.md index dbf4c028ec..8a2cae25ba 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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 ` diff --git a/packages/app/src/agent-skills/index.tsx b/packages/app/src/agent-skills/index.tsx index 1241192b50..9be1813dfe 100644 --- a/packages/app/src/agent-skills/index.tsx +++ b/packages/app/src/agent-skills/index.tsx @@ -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 = { add: 0, update: 1, delete: 2, }; -const ThemedBlocks = withUnistyles(Blocks); -const ThemedCheck = withUnistyles(Check); const ThemedSettings = withUnistyles(Settings2); -const ThemedArrowUpRight = withUnistyles(ArrowUpRight); const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills"; -const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); function formatUpdateMessage(ops: readonly AgentSkillOperation[], t: TFunction): string { @@ -35,91 +33,50 @@ function formatUpdateMessage(ops: readonly AgentSkillOperation[], t: TFunction): .join("\n"); } +/** Whether the host has Paseo's skills installed in any form, current or not. */ +export function areSkillsInstalled(status: AgentSkillsStatus | null): boolean { + return status !== null && status.state !== "not-installed"; +} + +/** Whether the saved selection names any skill there is to install. */ +export function hasSelectedSkills(status: AgentSkillsStatus | null): boolean { + if (!status) return false; + if (status.selection.mode === "all") return status.available.length > 0; + return status.selection.skills.some((name) => status.available.includes(name)); +} + export function AgentSkillsSection({ serverId }: { serverId: string }) { const { t } = useTranslation(); const skills = useAgentSkills(serverId); - const refreshSkills = skills.refresh; - const skillsConnected = skills.connected; - const skillsSupported = skills.supported; - const [isChoosing, setIsChoosing] = useState(false); + const { refresh, connected, supported, reconcile, uninstall } = skills; useFocusEffect( useCallback(() => { - if (skillsConnected && skillsSupported) void refreshSkills(); + if (connected && supported) void refresh(); return undefined; - }, [refreshSkills, skillsConnected, skillsSupported]), + }, [refresh, connected, supported]), ); - const maintenanceOps = useMemo( - () => skills.status?.ops.filter((op) => op.kind !== "delete") ?? [], - [skills.status?.ops], + const handleValueChange = useCallback( + (next: boolean) => { + void (next ? reconcile() : uninstall()); + }, + [reconcile, uninstall], ); - const state = - skills.status?.state === "drift" && maintenanceOps.length === 0 - ? "up-to-date" - : (skills.status?.state ?? null); - const hasSelected = - (skills.status?.selection.mode === "all" && skills.status.available.length > 0) || - (skills.status?.selection.mode === "custom" && - skills.status.selection.skills.some((name) => skills.status?.available.includes(name))); - - const handleReconcile = useCallback(async () => { - if (skills.isWorking) return; - const confirmed = await confirmDialog({ - title: t("settings.host.skills.updateTitle"), - message: - maintenanceOps.length > 0 - ? formatUpdateMessage(maintenanceOps, t) - : t("settings.host.skills.updateFallback"), - confirmLabel: t("settings.host.skills.actions.update"), - }); - if (confirmed) await skills.reconcile(); - }, [maintenanceOps, skills, t]); - const handleUninstall = useCallback(async () => { - if (skills.isWorking) return; - const confirmed = await confirmDialog({ - title: t("settings.host.skills.uninstallTitle"), - message: t("settings.host.skills.uninstallMessage"), - confirmLabel: t("settings.host.skills.actions.uninstall"), - destructive: true, - }); - if (confirmed) await skills.uninstall(); - }, [skills, t]); - const chooseIcon = useMemo( - () => , - [], - ); - const handleOpen = useCallback(() => setIsChoosing(true), []); - const handleClose = useCallback(() => setIsChoosing(false), []); - const handleOpenDocs = useCallback(() => { - void openExternalUrl(SKILLS_DOCS_URL); - }, []); - const docsIcon = useMemo( - () => , - [], - ); - const trailing = useMemo( - () => ( - - ), - [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 ( - + - {skills.connected + {connected ? t("settings.host.skills.unsupported") : t("settings.host.skills.unavailable")} @@ -130,109 +87,112 @@ export function AgentSkillsSection({ serverId }: { serverId: string }) { } return ( - - - - - - - {t("settings.host.skills.title")} - - - {state === "drift" - ? t("settings.host.skills.updateAvailable") - : t("settings.host.skills.description")} - - - - - - ); - } - if (state === "drift") { - return ( - - - - - ); - } + 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( + () => , + [], + ); + return ( - + <> + + + {needsUpdate ? ( + + ) : null} + diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index a1e3ac3411..a20362d815 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -36,6 +36,7 @@ import { DownloadToast } from "@/components/download-toast"; import { QuittingOverlay } from "@/components/quitting-overlay"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { ChangelogHost } from "@/changelog"; +import { OnboardingHost } from "@/onboarding"; import { AppDiagnosticHost } from "@/components/app-diagnostic-host"; import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import { LeftSidebar } from "@/components/left-sidebar"; @@ -496,6 +497,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon + diff --git a/packages/app/src/appearance/theme-picker.tsx b/packages/app/src/appearance/theme-picker.tsx new file mode 100644 index 0000000000..cb2ebf5983 --- /dev/null +++ b/packages/app/src/appearance/theme-picker.tsx @@ -0,0 +1,292 @@ +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Text, View, type PressableStateCallbackType } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { PressHighlight } from "@/components/ui/press-highlight"; +import { DEFAULT_THEME_PREFERENCE, useAppSettings, type AppSettings } from "@/hooks/use-settings"; +import type { PluginThemeOption } from "@/plugins/themes"; +import { + THEME_OPTIONS, + PLUGIN_THEME_PREFERENCE, + darkTheme, + lightTheme, + type Theme, +} from "@/styles/theme"; +import { useContributedThemes } from "./provider"; + +type BuiltInThemePreference = Exclude; + +// System leads the grid because it is the default. +const PICKER_THEME_OPTIONS = [ + ...THEME_OPTIONS.filter((option) => option.name === "auto"), + ...THEME_OPTIONS.filter((option) => option.name !== "auto"), +]; + +const PREVIEW_ASPECT_RATIO = 16 / 9; + +/** + * Every theme as a tile with a small preview drawn from its own tokens. Picking a tile applies + * the theme to the whole app at once. Settings → Appearance and first-run setup share it. + */ +export function ThemePicker() { + const { t } = useTranslation(); + const { settings, updateSettings } = useAppSettings(); + const { options: pluginThemes, selected: selectedPluginTheme, select } = useContributedThemes(); + // A selected contribution that is no longer installed shows the fallback the app renders. + const builtInValue = + settings.theme === PLUGIN_THEME_PREFERENCE ? DEFAULT_THEME_PREFERENCE : settings.theme; + + const selectBuiltIn = useCallback( + (theme: BuiltInThemePreference) => { + void updateSettings({ theme }); + }, + [updateSettings], + ); + + return ( + + {PICKER_THEME_OPTIONS.map((option) => ( + + ))} + {pluginThemes.map((option) => ( + + ))} + + ); +} + +function BuiltInThemeTile({ + value, + selected, + onSelect, +}: { + value: BuiltInThemePreference; + selected: boolean; + onSelect: (value: BuiltInThemePreference) => void; +}) { + const { t } = useTranslation(); + const handlePress = useCallback(() => onSelect(value), [onSelect, value]); + const option = THEME_OPTIONS.find((candidate) => candidate.name === value); + return ( + + ); +} + +function PluginThemeTile({ + option, + selected, + onSelect, +}: { + option: PluginThemeOption; + selected: boolean; + onSelect: (option: PluginThemeOption) => void; +}) { + const handlePress = useCallback(() => onSelect(option), [onSelect, option]); + return ( + + ); +} + +function ThemeTile({ + label, + selected, + onPress, + previewTheme, +}: { + label: string; + selected: boolean; + onPress: () => void; + /** "system" follows the OS, so its preview shows both halves. */ + previewTheme: Theme | "system"; +}) { + const accessibilityState = useMemo(() => ({ checked: selected }), [selected]); + const tileStyle = useCallback( + ({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.tile, + hovered && !selected && styles.tileHovered, + selected && styles.tileSelected, + ], + [selected], + ); + return ( + + + + {previewTheme === "system" ? ( + + ) : ( + + )} + + + {label} + + + + ); +} + +/** A sketch of the app — sidebar, a few lines of text and an accent bubble — in `theme`'s colors. */ +function ThemePreview({ theme }: { theme: Theme }) { + const colors = theme.colors; + const previewStyles = useMemo( + () => ({ + root: [styles.preview, { backgroundColor: colors.surface0 }], + sidebar: [styles.previewSidebar, { backgroundColor: colors.surfaceSidebar }], + mutedLine: { backgroundColor: colors.foregroundMuted }, + strongLine: { backgroundColor: colors.foreground }, + bubble: [styles.previewBubble, { backgroundColor: colors.accent }], + }), + [colors], + ); + return ( + + + + + + + + + + + + + + ); +} + +/** "System" follows the OS: light on the left half, dark on the right. */ +function SystemThemePreview() { + return ( + + + + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + // Columns fill the row: each cell is a fixed fraction of it, and the half-gap padding on every + // cell plus the negative margin here leaves exactly one gap between neighbors. + grid: { + flexDirection: "row", + flexWrap: "wrap", + margin: -theme.spacing[3] / 2, + }, + cell: { + width: { xs: "50%", md: "25%" }, + padding: theme.spacing[3] / 2, + }, + tile: { + padding: theme.spacing[1], + gap: theme.spacing[1], + borderRadius: theme.borderRadius.lg, + borderWidth: theme.borderWidth[2], + borderColor: "transparent", + }, + tileHovered: { + backgroundColor: theme.colors.surface2, + }, + tileSelected: { + borderColor: theme.colors.accent, + }, + previewFrame: { + borderRadius: theme.borderRadius.md, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + overflow: "hidden", + }, + label: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + paddingHorizontal: theme.spacing[1], + }, + preview: { + width: "100%", + aspectRatio: PREVIEW_ASPECT_RATIO, + flexDirection: "row", + overflow: "hidden", + }, + previewSidebar: { + width: 36, + paddingHorizontal: 6, + paddingVertical: 8, + gap: 5, + }, + previewMain: { + flex: 1, + padding: 8, + gap: 5, + }, + previewLine: { + height: 4, + borderRadius: 2, + opacity: 0.6, + }, + previewTitle: { + width: "55%", + height: 5, + borderRadius: 2, + }, + sidebarLineWide: { width: "100%" }, + sidebarLineNarrow: { width: "60%" }, + sidebarLineMedium: { width: "80%" }, + mainLineWide: { width: "90%" }, + mainLineNarrow: { width: "65%" }, + previewBubble: { + marginTop: "auto", + alignSelf: "flex-end", + width: "45%", + height: 10, + borderRadius: 5, + }, + systemDarkHalf: { + position: "absolute", + top: 0, + bottom: 0, + left: "50%", + width: "50%", + overflow: "hidden", + }, + // A full-width dark preview shifted left by half, so only its right half shows. + systemDarkOffset: { + width: "200%", + marginLeft: "-100%", + }, +})); diff --git a/packages/app/src/changelog/internal/changelog-sheet.tsx b/packages/app/src/changelog/internal/changelog-sheet.tsx index 04afdcafb0..7447b96225 100644 --- a/packages/app/src/changelog/internal/changelog-sheet.tsx +++ b/packages/app/src/changelog/internal/changelog-sheet.tsx @@ -118,7 +118,7 @@ function ChangelogBody({ state, shownReleases, onShowMore, onRetry }: ChangelogB description={t("changelog.error.description")} testID="changelog-error" > - diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 4ddf872daa..33e61f6ec6 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -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; + children: ReactNode; +}) { + return {children}; +} + 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 ( - + {header ? : null} {scrollable ? ( snapPoints ?? ["65%", "90%"], [snapPoints]); const footerView = useMemo( - () => (footer ? {footer} : null), + () => (footer ? {footer} : null), [footer, footerContainerStyle], ); useEffect(() => { @@ -730,7 +755,7 @@ export function AdaptiveModalSheet({ desktopHeight == null ? styles.desktopStaticContent : styles.compactStaticContent; const cardInner = ( - + {header ? : null} {scrollable ? ( {footerView} + {header ? null : ( + + + + )} ); diff --git a/packages/app/src/components/archived-agent-callout.tsx b/packages/app/src/components/archived-agent-callout.tsx index b5f6a3ea54..8930bf44c3 100644 --- a/packages/app/src/components/archived-agent-callout.tsx +++ b/packages/app/src/components/archived-agent-callout.tsx @@ -41,7 +41,6 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout {t("agentPanel.archived.callout")} ) : null} diff --git a/packages/app/src/components/project-edit-sheet.tsx b/packages/app/src/components/project-edit-sheet.tsx index d80fa2bad0..2da515b5c8 100644 --- a/packages/app/src/components/project-edit-sheet.tsx +++ b/packages/app/src/components/project-edit-sheet.tsx @@ -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({ - @@ -436,18 +436,11 @@ function renderProviderSheetFooter({ ) : null} - - diff --git a/packages/app/src/desktop/browser/settings/browser-data-section.tsx b/packages/app/src/desktop/browser/settings/browser-data-section.tsx index ad4140d683..6424750d86 100644 --- a/packages/app/src/desktop/browser/settings/browser-data-section.tsx +++ b/packages/app/src/desktop/browser/settings/browser-data-section.tsx @@ -66,7 +66,6 @@ export function BrowserDataSection() { )} diff --git a/packages/app/src/desktop/components/desktop-permissions-section.tsx b/packages/app/src/desktop/components/desktop-permissions-section.tsx index 74cc71dc4a..b2c261543b 100644 --- a/packages/app/src/desktop/components/desktop-permissions-section.tsx +++ b/packages/app/src/desktop/components/desktop-permissions-section.tsx @@ -41,7 +41,6 @@ export function DesktopPermissionsSection() { () => ( - + @@ -284,13 +281,12 @@ function DaemonInfoCard(props: DaemonInfoCardProps) { {daemonLogs?.logPath ? ( - ) : null} - ), - [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 ( {isLoading || isLoadingSettings ? ( @@ -471,7 +449,7 @@ export function LocalDaemonSection() { {daemonStatus.home} - - ), - [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 ( - - - - - - - - {t("settings.integrations.commandLine.title")} - - - - {t("settings.integrations.commandLine.description")} - - - {status?.installed ? ( - - - {t("settings.integrations.actions.installed")} - - ) : ( - - )} - - + + + + ); } -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 ( + + + + ); +} diff --git a/packages/app/src/desktop/components/pair-device-section.tsx b/packages/app/src/desktop/components/pair-device-section.tsx index 982469008a..bb5c853aca 100644 --- a/packages/app/src/desktop/components/pair-device-section.tsx +++ b/packages/app/src/desktop/components/pair-device-section.tsx @@ -22,6 +22,8 @@ import { } from "@/components/ui/text-input"; const RELAY_DOCS_URL = "https://paseo.sh/docs/security"; +export const CONNECTIVITY_DOCS_URL = "https://paseo.sh/docs/connectivity"; +export const TAILSCALE_DOCS_URL = `${CONNECTIVITY_DOCS_URL}#tailscale`; const FLEX_ONE_STYLE = { flex: 1 } as const; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedShieldCheck = withUnistyles(ShieldCheck); @@ -169,7 +171,7 @@ function OfferLoadError({ message, onRetry }: { message: string; onRetry: () => const { t } = useTranslation(); return ( - @@ -219,7 +221,14 @@ function RelayConsent(props: PairDeviceBodyProps) { - {t("pairing.device.directConnectionHint")} + + {t("pairing.device.directConnectionHint")} + + ); @@ -254,12 +263,7 @@ function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) { accessibilityLabel={t("pairing.link.label")} /> - @@ -338,8 +342,12 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, marginTop: 1, // optical: seats the glyph on the hint's first text line }, - directHint: { + directContent: { flex: 1, + alignItems: "flex-start", + gap: theme.spacing[2], + }, + directHint: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, lineHeight: theme.fontSize.sm * 1.5, diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index 87cc058a0c..ac1427363e 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -326,6 +326,10 @@ export async function installCli(): Promise { return parseInstallStatus(await invokeDesktopCommand("install_cli")); } +export async function uninstallCli(): Promise { + return parseInstallStatus(await invokeDesktopCommand("uninstall_cli")); +} + // COMPAT(desktopSkillSelectionMigration): added in v0.4.0; remove after 2027-02-16. export function readLegacySkillSelection(): Promise { return invokeDesktopCommand("read_legacy_skill_selection") as Promise; diff --git a/packages/app/src/desktop/hooks/use-install-status.ts b/packages/app/src/desktop/hooks/use-install-status.ts index 98bc3da804..5a688da449 100644 --- a/packages/app/src/desktop/hooks/use-install-status.ts +++ b/packages/app/src/desktop/hooks/use-install-status.ts @@ -1,10 +1,12 @@ import { useCallback } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; +import { useToast } from "@/contexts/toast-context"; import { getCliInstallStatus, installCli, shouldUseDesktopDaemon, + uninstallCli, type InstallStatus, } from "@/desktop/daemon/desktop-daemon"; import { @@ -14,60 +16,85 @@ import { const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const; -interface DesktopInstallHookResult { +interface CliInstall { + /** False outside the desktop app: the CLI installs on the machine running the app. */ + available: boolean; status: InstallStatus | null; isLoading: boolean; - isInstalling: boolean; - error: Error | null; - install: () => void; + isWorking: boolean; + /** + * Installs or removes the CLI; resolves to whether it worked. Failures are reported. Removing + * keeps a paseo command this app did not install, says so, and resolves true. + */ + setInstalled: (installed: boolean) => Promise; refresh: () => void; } -export function useCliInstall(): DesktopInstallHookResult { +export function useCliInstall(): CliInstall { const { t } = useTranslation(); const queryClient = useQueryClient(); const reportError = useDesktopIpcErrorReporter(); - const enabled = shouldUseDesktopDaemon(); + const toast = useToast(); + const available = shouldUseDesktopDaemon(); const statusQuery = useQuery({ 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({ - mutationFn: installCli, - onError: (error) => { + const mutation = useMutation({ + 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(CLI_INSTALL_STATUS_QUERY_KEY, nextStatus); - void queryClient.invalidateQueries({ queryKey: CLI_INSTALL_STATUS_QUERY_KEY }); }, }); - const { error: installError, isPending: isInstalling, mutate: install } = installMutation; + const { mutateAsync, isPending } = mutation; + // Removing only ever deletes the CLI this app installed. A paseo command put there some other way + // stays, and that still counts as done: the user is told rather than left with a switch that + // silently won't turn off. + const setInstalled = useCallback( + (installed: boolean) => + mutateAsync(installed).then( + (status) => { + if (status.installed === installed) return true; + if (installed) return false; + toast.show(t("desktop.integrations.cli.keptForeign")); + return true; + }, + () => false, + ), + [mutateAsync, t, toast], + ); const refresh = useCallback(() => { void refetch(); }, [refetch]); return { + available, status: installStatus ?? null, isLoading, - isInstalling, - error: statusError ?? installError ?? null, - install, + isWorking: isPending, + setInstalled, refresh, }; } diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index 5f70030819..3674ed7a50 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -90,7 +90,7 @@ export function FilePane({ {snapshot.status === "error" ? snapshot.error : t("panels.file.notFound")} - diff --git a/packages/app/src/file-panels/source/conflict-alert.tsx b/packages/app/src/file-panels/source/conflict-alert.tsx index 94a76f517d..7d3a27990c 100644 --- a/packages/app/src/file-panels/source/conflict-alert.tsx +++ b/packages/app/src/file-panels/source/conflict-alert.tsx @@ -30,11 +30,11 @@ export function FileConflictAlert({ state }: { state: FileConflictAlertState }) {state.kind === "changed" ? ( {state.canOverwrite ? ( - ) : null} - diff --git a/packages/app/src/file-panels/source/panel.tsx b/packages/app/src/file-panels/source/panel.tsx index 23ea42f875..906719bce6 100644 --- a/packages/app/src/file-panels/source/panel.tsx +++ b/packages/app/src/file-panels/source/panel.tsx @@ -206,7 +206,7 @@ function EditableTextFile({ return ( {snapshot.error ?? t("panels.file.failedToLoad")} - diff --git a/packages/app/src/file-panels/source/toolbar.tsx b/packages/app/src/file-panels/source/toolbar.tsx index 43cb6f7291..4cf391b5d6 100644 --- a/packages/app/src/file-panels/source/toolbar.tsx +++ b/packages/app/src/file-panels/source/toolbar.tsx @@ -108,7 +108,7 @@ export function TextFileToolbar({ {view && onViewChange ? ( ({ }, 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", diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 3dd84f256a..08e584b3cc 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -1134,7 +1134,7 @@ function DiffBodyContent({ {emptyAction ? ( diff --git a/packages/app/src/git/pull-request-panel/pane-skeleton.tsx b/packages/app/src/git/pull-request-panel/pane-skeleton.tsx index 9ddeeadf2c..d7ab363e49 100644 --- a/packages/app/src/git/pull-request-panel/pane-skeleton.tsx +++ b/packages/app/src/git/pull-request-panel/pane-skeleton.tsx @@ -2,7 +2,8 @@ import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar"; -import { PrActivitySkeleton, SkeletonPulse, useSkeletonPulse } from "./activity-skeleton"; +import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton"; +import { PrActivitySkeleton } from "./activity-skeleton"; const CHECK_ROW_KEYS = [0, 1, 2].map((i) => `pr-pane-skeleton-check-${i}`); diff --git a/packages/app/src/git/pull-request-panel/pane.tsx b/packages/app/src/git/pull-request-panel/pane.tsx index 429dd2be0e..1f36a8749e 100644 --- a/packages/app/src/git/pull-request-panel/pane.tsx +++ b/packages/app/src/git/pull-request-panel/pane.tsx @@ -484,7 +484,7 @@ export function PullRequestPane({ - @@ -95,10 +95,10 @@ export function AgentRouteResolutionView({ {!isConnecting ? ( - - diff --git a/packages/app/src/onboarding/index.ts b/packages/app/src/onboarding/index.ts new file mode 100644 index 0000000000..68c80d8f0d --- /dev/null +++ b/packages/app/src/onboarding/index.ts @@ -0,0 +1,9 @@ +/** + * First-run setup: a one-time modal that walks a new user through the surfaces Settings + * already has — appearance, providers, projects, Paseo tools and phone pairing. + * + * The app shell mounts the host; Settings reopens it. Whether it opens, which steps it has, + * and how completion is remembered stay inside; import from `@/onboarding` only. + */ +export { OnboardingHost } from "./onboarding-host"; +export { useOpenOnboarding } from "./use-open-onboarding"; diff --git a/packages/app/src/onboarding/internal/completion-storage.ts b/packages/app/src/onboarding/internal/completion-storage.ts new file mode 100644 index 0000000000..f50ef7cacf --- /dev/null +++ b/packages/app/src/onboarding/internal/completion-storage.ts @@ -0,0 +1,25 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { z } from "zod"; +import { readValidatedJson } from "@/storage/validated-storage"; +import type { OnboardingCompletionStorage } from "./model"; + +export const ONBOARDING_STORAGE_KEY = "@paseo:onboarding"; + +const OnboardingRecordSchema = z.object({ completedAt: z.string() }); + +export const asyncStorageOnboardingCompletion: OnboardingCompletionStorage = { + async isCompleted() { + const record = await readValidatedJson( + AsyncStorage, + ONBOARDING_STORAGE_KEY, + OnboardingRecordSchema, + ); + return record !== null; + }, + async markCompleted() { + await AsyncStorage.setItem( + ONBOARDING_STORAGE_KEY, + JSON.stringify({ completedAt: new Date().toISOString() }), + ); + }, +}; diff --git a/packages/app/src/onboarding/internal/model.test.ts b/packages/app/src/onboarding/internal/model.test.ts new file mode 100644 index 0000000000..5838876e91 --- /dev/null +++ b/packages/app/src/onboarding/internal/model.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createOnboardingModel, + type OnboardingCompletionStorage, + type OnboardingModel, + type OnboardingSetupHost, +} from "./model"; + +class MemoryCompletionStorage implements OnboardingCompletionStorage { + completed: boolean; + + constructor(completed = false) { + this.completed = completed; + } + + async isCompleted(): Promise { + return this.completed; + } + + async markCompleted(): Promise { + this.completed = true; + } +} + +const FRESH_HOST: OnboardingSetupHost = { + serverId: "local", + isConnected: true, + hasHydratedWorkspaces: true, + hasProjects: false, +}; + +/** Presses Continue on whatever step is showing. */ +function advance(model: OnboardingModel) { + const state = model.getState(); + if (state.status !== "open") throw new Error("setup is not open"); + model.next(state.visit); +} + +function currentVisit(model: OnboardingModel): number { + const state = model.getState(); + if (state.status !== "open") throw new Error("setup is not open"); + return state.visit; +} + +async function openModel(storage = new MemoryCompletionStorage()) { + const model = createOnboardingModel({ storage }); + await model.ready; + return model; +} + +describe("onboarding model", () => { + it("opens on the appearance step for a connected host with no projects", async () => { + const model = await openModel(); + + model.applySetupHost(FRESH_HOST, { includePhoneStep: true }); + + expect(model.getState()).toMatchObject({ + status: "open", + serverId: "local", + steps: ["appearance", "providers", "projects", "orchestration", "phone"], + stepIndex: 0, + }); + }); + + it("waits until the host is connected and its workspaces are hydrated", async () => { + const model = await openModel(); + + model.applySetupHost(null, { includePhoneStep: true }); + model.applySetupHost({ ...FRESH_HOST, isConnected: false }, { includePhoneStep: true }); + model.applySetupHost( + { ...FRESH_HOST, hasHydratedWorkspaces: false }, + { includePhoneStep: true }, + ); + expect(model.getState()).toEqual({ status: "deciding" }); + + model.applySetupHost(FRESH_HOST, { includePhoneStep: true }); + expect(model.getState().status).toBe("open"); + }); + + it("stays hidden for a host that already has projects, even after they are removed", async () => { + const model = await openModel(); + + model.applySetupHost({ ...FRESH_HOST, hasProjects: true }, { includePhoneStep: true }); + model.applySetupHost(FRESH_HOST, { includePhoneStep: true }); + + expect(model.getState()).toEqual({ status: "hidden" }); + }); + + it("stays hidden once setup was completed on this client", async () => { + const model = await openModel(new MemoryCompletionStorage(true)); + + model.applySetupHost(FRESH_HOST, { includePhoneStep: true }); + + expect(model.getState()).toEqual({ status: "hidden" }); + }); + + it("decides only after the completion record has loaded", async () => { + const model = createOnboardingModel({ storage: new MemoryCompletionStorage(true) }); + + model.applySetupHost(FRESH_HOST, { includePhoneStep: true }); + expect(model.getState()).toEqual({ status: "deciding" }); + + await model.ready; + expect(model.getState()).toEqual({ status: "hidden" }); + }); + + it("keeps the modal open when the host gains its first project during setup", async () => { + const model = await openModel(); + model.applySetupHost(FRESH_HOST, { includePhoneStep: true }); + advance(model); + advance(model); + + model.applySetupHost({ ...FRESH_HOST, hasProjects: true }, { includePhoneStep: true }); + + expect(model.getState()).toMatchObject({ status: "open", stepIndex: 2 }); + }); + + it("omits the phone step when the client is a phone", async () => { + const model = await openModel(); + + model.applySetupHost(FRESH_HOST, { includePhoneStep: false }); + + expect(model.getState()).toMatchObject({ + steps: ["appearance", "providers", "projects", "orchestration"], + }); + }); + + it("moves between steps without leaving the step list", async () => { + const model = await openModel(); + model.applySetupHost(FRESH_HOST, { includePhoneStep: false }); + + model.back(); + expect(model.getState()).toMatchObject({ stepIndex: 0 }); + advance(model); + advance(model); + advance(model); + expect(model.getState()).toMatchObject({ stepIndex: 3 }); + model.goTo("providers"); + expect(model.getState()).toMatchObject({ stepIndex: 1 }); + model.back(); + expect(model.getState()).toMatchObject({ stepIndex: 0 }); + }); + + it("ignores a late advance from a step visit the user already left", async () => { + const model = await openModel(new MemoryCompletionStorage(true)); + model.reopen("local", { includePhoneStep: true }); + model.goTo("projects"); + + // Projects are still being added when the user leaves Projects and comes back. + const addingFrom = currentVisit(model); + model.goTo("appearance"); + model.goTo("projects"); + model.next(addingFrom); + expect(model.getState()).toMatchObject({ stepIndex: 2 }); + + // Or closes setup, opens it again and returns to Projects before the add finishes. + await model.complete(); + model.reopen("local", { includePhoneStep: true }); + model.goTo("projects"); + model.next(addingFrom); + expect(model.getState()).toMatchObject({ status: "open", stepIndex: 2 }); + }); + + it("moving on from the last step finishes setup, unless the user left that step first", async () => { + const storage = new MemoryCompletionStorage(); + const model = await openModel(storage); + model.applySetupHost(FRESH_HOST, { includePhoneStep: false }); + model.goTo("orchestration"); + + // Finish is applying choices when the user goes back to Projects. + const finishingFrom = currentVisit(model); + model.back(); + model.next(finishingFrom); + expect(model.getState()).toMatchObject({ status: "open", stepIndex: 2 }); + expect(storage.completed).toBe(false); + + advance(model); + advance(model); + await vi.waitFor(() => expect(storage.completed).toBe(true)); + expect(model.getState()).toEqual({ status: "hidden" }); + }); + + it("records completion when setup closes, so it does not open again", async () => { + const storage = new MemoryCompletionStorage(); + const model = await openModel(storage); + model.applySetupHost(FRESH_HOST, { includePhoneStep: true }); + + await model.complete(); + + expect(model.getState()).toEqual({ status: "hidden" }); + expect(storage.completed).toBe(true); + const relaunched = await openModel(storage); + relaunched.applySetupHost(FRESH_HOST, { includePhoneStep: true }); + expect(relaunched.getState()).toEqual({ status: "hidden" }); + }); + + it("reopens on the first step for an existing user", async () => { + const model = await openModel(new MemoryCompletionStorage(true)); + model.applySetupHost({ ...FRESH_HOST, hasProjects: true }, { includePhoneStep: true }); + + model.reopen("local", { includePhoneStep: true }); + + expect(model.getState()).toMatchObject({ status: "open", serverId: "local", stepIndex: 0 }); + }); + + it("notifies subscribers on every state change", async () => { + const model = await openModel(); + const seen: string[] = []; + model.subscribe(() => { + const state = model.getState(); + seen.push(state.status === "open" ? `open:${state.stepIndex}` : state.status); + }); + + model.applySetupHost(FRESH_HOST, { includePhoneStep: true }); + advance(model); + await model.complete(); + + expect(seen).toEqual(["open:0", "open:1", "hidden"]); + }); +}); diff --git a/packages/app/src/onboarding/internal/model.ts b/packages/app/src/onboarding/internal/model.ts new file mode 100644 index 0000000000..f8aaf46d4f --- /dev/null +++ b/packages/app/src/onboarding/internal/model.ts @@ -0,0 +1,170 @@ +export type OnboardingStepId = "appearance" | "providers" | "projects" | "orchestration" | "phone"; + +const STEPS_WITHOUT_PHONE: readonly OnboardingStepId[] = [ + "appearance", + "providers", + "projects", + "orchestration", +]; +const STEPS_WITH_PHONE: readonly OnboardingStepId[] = [...STEPS_WITHOUT_PHONE, "phone"]; + +export interface OnboardingCompletionStorage { + isCompleted(): Promise; + markCompleted(): Promise; +} + +/** The host setup runs against: the desktop's managed daemon, otherwise the first saved host. */ +export interface OnboardingSetupHost { + serverId: string; + isConnected: boolean; + hasHydratedWorkspaces: boolean; + hasProjects: boolean; +} + +export interface OnboardingStepOptions { + /** False when the client is itself a phone, where pairing a phone makes no sense. */ + includePhoneStep: boolean; +} + +export type OnboardingState = + | { status: "deciding" } + | { status: "hidden" } + | { + status: "open"; + serverId: string; + steps: readonly OnboardingStepId[]; + stepIndex: number; + /** Identifies this stay on the current step; it changes on every move, close or reopen. */ + visit: number; + }; + +export interface OnboardingModel { + /** Resolves once the completion record has loaded. */ + ready: Promise; + getState(): OnboardingState; + subscribe(listener: () => void): () => void; + applySetupHost(host: OnboardingSetupHost | null, options: OnboardingStepOptions): void; + reopen(serverId: string, options: OnboardingStepOptions): void; + /** + * Moves on from the step visit the caller saw; from the last step, that finishes setup. A no-op + * once that visit ended, so an action that finishes late never moves a step the user left or + * closes a setup they went back in, or closed and reopened. + */ + next(visit: number): void; + back(): void; + goTo(step: OnboardingStepId): void; + /** Closes setup, whether the user finished or skipped it, and remembers that on this client. */ + complete(): Promise; +} + +type Completion = "loading" | "completed" | "pending"; + +interface PendingHost { + host: OnboardingSetupHost | null; + options: OnboardingStepOptions; +} + +function isReadyForSetup(host: OnboardingSetupHost | null): host is OnboardingSetupHost { + return host !== null && host.isConnected && host.hasHydratedWorkspaces; +} + +export function createOnboardingModel(input: { + storage: OnboardingCompletionStorage; +}): OnboardingModel { + const listeners = new Set<() => void>(); + let state: OnboardingState = { status: "deciding" }; + let completion: Completion = "loading"; + let pendingHost: PendingHost | null = null; + let visits = 0; + + function openState(serverId: string, options: OnboardingStepOptions): OnboardingState { + visits += 1; + return { + status: "open", + serverId, + steps: options.includePhoneStep ? STEPS_WITH_PHONE : STEPS_WITHOUT_PHONE, + stepIndex: 0, + visit: visits, + }; + } + + function setState(next: OnboardingState) { + state = next; + for (const listener of listeners) { + listener(); + } + } + + // The gate is decided once per launch: a host that later loses its projects, or a + // project added during setup, never flips the decision. + function decide() { + if (state.status !== "deciding" || completion === "loading") return; + if (completion === "completed") { + setState({ status: "hidden" }); + return; + } + const host = pendingHost?.host ?? null; + if (!pendingHost || !isReadyForSetup(host)) return; + setState( + host.hasProjects ? { status: "hidden" } : openState(host.serverId, pendingHost.options), + ); + } + + function moveTo(stepIndex: number) { + if (state.status !== "open") return; + const clamped = Math.max(0, Math.min(stepIndex, state.steps.length - 1)); + if (clamped === state.stepIndex) return; + visits += 1; + setState({ ...state, stepIndex: clamped, visit: visits }); + } + + async function loadCompletion(): Promise { + try { + completion = (await input.storage.isCompleted()) ? "completed" : "pending"; + } catch { + // An unreadable record must not trap an existing user in setup. + completion = "completed"; + } + decide(); + } + + const ready = loadCompletion(); + + async function complete() { + completion = "completed"; + setState({ status: "hidden" }); + await input.storage.markCompleted(); + } + + return { + ready, + getState: () => state, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + applySetupHost(host, options) { + pendingHost = { host, options }; + decide(); + }, + reopen(serverId, options) { + setState(openState(serverId, options)); + }, + next(visit) { + if (state.status !== "open" || state.visit !== visit) return; + if (state.stepIndex === state.steps.length - 1) void complete(); + else moveTo(state.stepIndex + 1); + }, + back() { + if (state.status === "open") moveTo(state.stepIndex - 1); + }, + goTo(step) { + if (state.status !== "open") return; + const stepIndex = state.steps.indexOf(step); + if (stepIndex >= 0) moveTo(stepIndex); + }, + complete, + }; +} diff --git a/packages/app/src/onboarding/internal/onboarding-instance.ts b/packages/app/src/onboarding/internal/onboarding-instance.ts new file mode 100644 index 0000000000..d6f73456fb --- /dev/null +++ b/packages/app/src/onboarding/internal/onboarding-instance.ts @@ -0,0 +1,10 @@ +import { asyncStorageOnboardingCompletion } from "./completion-storage"; +import { createOnboardingModel, type OnboardingModel } from "./model"; + +let instance: OnboardingModel | null = null; + +/** The app's one setup model, created on first use so storage is read once per launch. */ +export function getOnboardingModel(): OnboardingModel { + instance ??= createOnboardingModel({ storage: asyncStorageOnboardingCompletion }); + return instance; +} diff --git a/packages/app/src/onboarding/internal/onboarding-modal.tsx b/packages/app/src/onboarding/internal/onboarding-modal.tsx new file mode 100644 index 0000000000..0eb0418185 --- /dev/null +++ b/packages/app/src/onboarding/internal/onboarding-modal.tsx @@ -0,0 +1,394 @@ +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Text, View, type PressableStateCallbackType } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Check } from "lucide-react-native"; +import { + AdaptiveModalSheet, + FULL_SHEET_SNAP_POINTS, + SheetFooter, +} from "@/components/adaptive-modal-sheet"; +import { PaseoLogo } from "@/components/icons/paseo-logo"; +import { Button } from "@/components/ui/button"; +import { PressHighlight } from "@/components/ui/press-highlight"; +import { ScrollView } from "@/components/ui/scroll-view"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { useProjectSuggestions, type ProjectSuggestionsController } from "@/project-suggestions"; +import type { Theme } from "@/styles/theme"; +import type { OnboardingModel, OnboardingState, OnboardingStepId } from "./model"; +import { useOrchestrationSetup, type OrchestrationSetupController } from "./orchestration-setup"; +import { OnboardingStepContent } from "./steps"; + +type OpenState = Extract; + +const ThemedCheck = withUnistyles(Check); +const accentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground }); + +export function OnboardingModal({ model, state }: { model: OnboardingModel; state: OpenState }) { + const { t } = useTranslation(); + const isCompact = useIsCompactFormFactor(); + const step = state.steps[state.stepIndex]; + const isFirst = state.stepIndex === 0; + const isLast = state.stepIndex === state.steps.length - 1; + // Held here so Continue can add the checked folders; reading history from the first step means + // the Projects step rarely waits. + const projectSuggestions = useProjectSuggestions(state.serverId); + const { addChecked } = projectSuggestions; + const orchestration = useOrchestrationSetup(state.serverId); + const { apply: applyOrchestration } = orchestration; + + const handleComplete = useCallback(() => { + void model.complete(); + }, [model]); + const handleBack = useCallback(() => model.back(), [model]); + const { visit } = state; + // A step's own work runs before it moves on; from the last step, moving on finishes setup. + const handleAdvance = useCallback(() => model.next(visit), [model, visit]); + const handleAddSuggestions = useCallback(() => { + void (async () => { + if (await addChecked()) handleAdvance(); + })(); + }, [addChecked, handleAdvance]); + const handleApplyOrchestration = useCallback(() => { + void (async () => { + if (await applyOrchestration()) handleAdvance(); + })(); + }, [applyOrchestration, handleAdvance]); + const primary = usePrimaryAction({ + step, + isLast, + projectSuggestions, + orchestration, + onAdvance: handleAdvance, + onAddSuggestions: handleAddSuggestions, + onApplyOrchestration: handleApplyOrchestration, + }); + + // The compact sheet stacks a title row, the step and the actions. The desktop card gives the + // step rail the full height instead: the brand tops the rail, the actions close the step pane, + // and the sheet floats its close button. + const compactHeader = useMemo( + () => ({ + title: t("onboarding.setup.title"), + leading: , + subtitle: ( + + {t("onboarding.setup.stepCounter", { + current: state.stepIndex + 1, + total: state.steps.length, + })} + + ), + }), + [state.stepIndex, state.steps.length, t], + ); + + const actions = ( + <> + + {isLast ? null : ( + + )} + + + {isFirst ? null : ( + + )} + + + + ); + + return ( + + + {isCompact ? null : } + + + {/* Padding lives on a wrapper: the ScrollView's contentContainerStyle skips Unistyles. */} + + + + {t(`onboarding.setup.steps.${step}.title`)} + + + {t(`onboarding.setup.steps.${step}.description`)} + + + + + + {isCompact ? null : {actions}} + + + + ); +} + +/** + * The footer's main button. On Projects, with folders checked, it adds them and moves on only once + * every one was added. On Orchestration it applies the switches and moves on once they all took. + * Otherwise it moves on. Moving on from the last step finishes setup, so that step's work still + * runs first; Orchestration is last in the compact layout. + */ +function usePrimaryAction(input: { + step: OnboardingStepId; + isLast: boolean; + projectSuggestions: ProjectSuggestionsController; + orchestration: OrchestrationSetupController; + onAdvance: () => void; + onAddSuggestions: () => void; + onApplyOrchestration: () => void; +}): { label: string; busy: boolean; onPress: () => void } { + const { t } = useTranslation(); + const { + step, + isLast, + projectSuggestions, + orchestration, + onAdvance, + onAddSuggestions, + onApplyOrchestration, + } = input; + const { checkedCount, isAdding } = projectSuggestions; + const advanceLabel = isLast ? t("onboarding.setup.finish") : t("onboarding.setup.continue"); + // Rows leave the list as they are added, so the count can reach zero while adding. + if (step === "projects" && (isAdding || checkedCount > 0)) { + return { + label: isAdding + ? t("onboarding.setup.steps.projects.suggestions.adding") + : t("onboarding.setup.steps.projects.suggestions.addSelected", { count: checkedCount }), + busy: isAdding, + onPress: onAddSuggestions, + }; + } + if (step === "orchestration") { + return { label: advanceLabel, busy: orchestration.isApplying, onPress: onApplyOrchestration }; + } + return { label: advanceLabel, busy: false, onPress: onAdvance }; +} + +function StepRail({ + state, + onSelect, +}: { + state: OpenState; + onSelect: (step: OnboardingStepId) => void; +}) { + const { t } = useTranslation(); + return ( + + + + + {t("onboarding.setup.title")} + + + + {state.steps.map((step, index) => ( + + ))} + + + ); +} + +function StepRailItem({ + step, + number, + current, + done, + onSelect, +}: { + step: OnboardingStepId; + number: number; + current: boolean; + done: boolean; + onSelect: (step: OnboardingStepId) => void; +}) { + const { t } = useTranslation(); + const label = t(`onboarding.setup.steps.${step}.label`); + const handlePress = useCallback(() => onSelect(step), [onSelect, step]); + const accessibilityState = useMemo(() => ({ selected: current }), [current]); + const itemStyle = useCallback( + ({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.railItem, + hovered && !current && styles.railItemHovered, + current && styles.railItemCurrent, + ], + [current], + ); + return ( + + + {done ? ( + + ) : ( + {number} + )} + + + {label} + + + ); +} + +const BADGE_SIZE = 20; +const LOGO_SIZE = 20; + +const styles = StyleSheet.create((theme) => ({ + content: { + padding: 0, + gap: 0, + }, + body: { + flex: 1, + minHeight: 0, + flexDirection: "row", + }, + rail: { + width: 200, + padding: theme.spacing[3], + borderRightWidth: theme.borderWidth[1], + borderRightColor: theme.colors.surface2, + backgroundColor: theme.colors.surface1, + }, + // Inset like a rail item, so the logo sits on the badges' column. + railBrand: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[2], + marginBottom: theme.spacing[4], + }, + railTitle: { + flexShrink: 1, + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.medium, + }, + railSteps: { + gap: theme.spacing[1], + }, + railItem: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[2], + borderRadius: theme.borderRadius.full, + }, + railItemHovered: { + backgroundColor: theme.colors.surface2, + }, + railItemCurrent: { + backgroundColor: theme.colors.surface2, + }, + railBadge: { + width: BADGE_SIZE, + height: BADGE_SIZE, + borderRadius: BADGE_SIZE / 2, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + alignItems: "center", + justifyContent: "center", + }, + railBadgeActive: { + width: BADGE_SIZE, + height: BADGE_SIZE, + borderRadius: BADGE_SIZE / 2, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.accent, + backgroundColor: theme.colors.accent, + alignItems: "center", + justifyContent: "center", + }, + railBadgeText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + }, + railBadgeTextActive: { + color: theme.colors.accentForeground, + fontSize: theme.fontSize.base, + }, + railLabel: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + }, + railLabelCurrent: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.medium, + }, + pane: { + flex: 1, + minHeight: 0, + }, + paneScroll: { + flex: 1, + minHeight: 0, + }, + paneContent: { + padding: theme.spacing[6], + gap: theme.spacing[6], + }, + stepHeader: { + gap: theme.spacing[2], + }, + stepTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.xl, + fontWeight: theme.fontWeight.medium, + }, + stepDescription: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + lineHeight: theme.fontSize.base * 1.5, + }, + stepCounter: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + footerActions: { + flexDirection: "row", + gap: theme.spacing[2], + }, +})); diff --git a/packages/app/src/onboarding/internal/orchestration-setup.ts b/packages/app/src/onboarding/internal/orchestration-setup.ts new file mode 100644 index 0000000000..5606379dab --- /dev/null +++ b/packages/app/src/onboarding/internal/orchestration-setup.ts @@ -0,0 +1,113 @@ +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + areSkillsInstalled, + hasSelectedSkills, + useAgentSkills, + type AgentSkillsController, +} from "@/agent-skills"; +import { useToast } from "@/contexts/toast-context"; +import { useCliInstall } from "@/desktop/hooks/use-install-status"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; + +export type OrchestrationChoice = "tools" | "skills" | "cli"; +type Choices = Record; + +const ALL_ON: Choices = { tools: true, skills: true, cli: true }; + +export interface OrchestrationSetupController { + choices: Choices; + setChoice: (choice: OrchestrationChoice, on: boolean) => void; + /** Only the choices this host and app can act on: skills need host support, the CLI the desktop app. */ + offered: Choices; + /** The skills row's picker and Update act on the host directly, through this. */ + skills: AgentSkillsController; + isApplying: boolean; + /** + * Brings the host in line with the choices; resolves true only when every one took. Each failure + * is shown to the user. + */ + apply: () => Promise; +} + +/** + * The Orchestration step's choices. Everything starts on and nothing touches the host or this + * computer until Continue applies them, the way checked projects are added. + */ +export function useOrchestrationSetup(serverId: string): OrchestrationSetupController { + const { t } = useTranslation(); + const toast = useToast(); + const skills = useAgentSkills(serverId); + const cli = useCliInstall(); + const { config, patchConfig } = useDaemonConfig(serverId); + const [chosen, setChosen] = useState(ALL_ON); + // With nothing picked there is nothing to install, so skills read as off, as they do in Settings. + const skillsSelected = skills.status === null || hasSelectedSkills(skills.status); + const choices = useMemo( + () => ({ ...chosen, skills: chosen.skills && skillsSelected }), + [chosen, skillsSelected], + ); + const [isApplying, setIsApplying] = useState(false); + const offered: Choices = { + tools: true, + skills: skills.connected && skills.supported, + cli: cli.available, + }; + + const setChoice = useCallback((choice: OrchestrationChoice, on: boolean) => { + setChosen((current) => ({ ...current, [choice]: on })); + }, []); + + // What the host and this computer have now, or null while it is still being read. A choice is + // skipped only when it is known to match; an unknown state is always written. + const toolsOn = config ? config.mcp.injectIntoAgents !== false : null; + const skillsInstalled = skills.status ? areSkillsInstalled(skills.status) : null; + const cliInstalled = cli.status ? cli.status.installed : null; + const { reconcile, uninstall } = skills; + const { setInstalled: setCliInstalled } = cli; + const offersSkills = offered.skills; + const offersCli = offered.cli; + + const setTools = useCallback( + (on: boolean) => + patchConfig({ mcp: { injectIntoAgents: on } }) + .then(Boolean, () => false) + .then((done) => { + if (!done) toast.error(t("settings.host.orchestration.enableTools.updateFailed")); + return done; + }), + [patchConfig, t, toast], + ); + const setSkillsInstalled = useCallback( + (on: boolean) => (on ? reconcile() : uninstall()), + [reconcile, uninstall], + ); + + const apply = useCallback(async () => { + setIsApplying(true); + try { + const results = await Promise.all([ + choices.tools === toolsOn ? true : setTools(choices.tools), + !offersSkills || choices.skills === skillsInstalled + ? true + : setSkillsInstalled(choices.skills), + !offersCli || choices.cli === cliInstalled ? true : setCliInstalled(choices.cli), + ]); + return results.every(Boolean); + } finally { + setIsApplying(false); + } + }, [ + choices, + toolsOn, + setTools, + offersSkills, + skillsInstalled, + setSkillsInstalled, + offersCli, + cliInstalled, + setCliInstalled, + ]); + + return { choices, setChoice, offered, skills, isApplying, apply }; +} diff --git a/packages/app/src/onboarding/internal/steps/appearance-step.tsx b/packages/app/src/onboarding/internal/steps/appearance-step.tsx new file mode 100644 index 0000000000..54ee902a62 --- /dev/null +++ b/packages/app/src/onboarding/internal/steps/appearance-step.tsx @@ -0,0 +1,5 @@ +import { ThemePicker } from "@/appearance/theme-picker"; + +export function AppearanceStep() { + return ; +} diff --git a/packages/app/src/onboarding/internal/steps/index.tsx b/packages/app/src/onboarding/internal/steps/index.tsx new file mode 100644 index 0000000000..ca1fd9060a --- /dev/null +++ b/packages/app/src/onboarding/internal/steps/index.tsx @@ -0,0 +1,33 @@ +import type { ProjectSuggestionsController } from "@/project-suggestions"; +import type { OnboardingStepId } from "../model"; +import type { OrchestrationSetupController } from "../orchestration-setup"; +import { AppearanceStep } from "./appearance-step"; +import { OrchestrationStep } from "./orchestration-step"; +import { PhoneStep } from "./phone-step"; +import { ProjectsStep } from "./projects-step"; +import { ProvidersStep } from "./providers-step"; + +export function OnboardingStepContent({ + step, + serverId, + projectSuggestions, + orchestration, +}: { + step: OnboardingStepId; + serverId: string; + projectSuggestions: ProjectSuggestionsController; + orchestration: OrchestrationSetupController; +}) { + switch (step) { + case "appearance": + return ; + case "providers": + return ; + case "projects": + return ; + case "orchestration": + return ; + case "phone": + return ; + } +} diff --git a/packages/app/src/onboarding/internal/steps/orchestration-step.tsx b/packages/app/src/onboarding/internal/steps/orchestration-step.tsx new file mode 100644 index 0000000000..da41f591b9 --- /dev/null +++ b/packages/app/src/onboarding/internal/steps/orchestration-step.tsx @@ -0,0 +1,30 @@ +import { useCallback } from "react"; +import { AgentSkillsRow } from "@/agent-skills"; +import { SettingsCard } from "@/components/settings"; +import { CommandLineRow } from "@/desktop/components/integrations-section"; +import { PaseoToolsRow } from "@/screens/settings/paseo-tools-card"; +import type { OrchestrationSetupController } from "../orchestration-setup"; + +/** Three switches, all on to start; Continue applies them. */ +export function OrchestrationStep({ setup }: { setup: OrchestrationSetupController }) { + const { choices, setChoice, offered, isApplying } = setup; + const setTools = useCallback((on: boolean) => setChoice("tools", on), [setChoice]); + const setSkills = useCallback((on: boolean) => setChoice("skills", on), [setChoice]); + const setCli = useCallback((on: boolean) => setChoice("cli", on), [setChoice]); + return ( + + + {offered.skills ? ( + + ) : null} + {offered.cli ? ( + + ) : null} + + ); +} diff --git a/packages/app/src/onboarding/internal/steps/phone-step.tsx b/packages/app/src/onboarding/internal/steps/phone-step.tsx new file mode 100644 index 0000000000..582c23a06b --- /dev/null +++ b/packages/app/src/onboarding/internal/steps/phone-step.tsx @@ -0,0 +1,105 @@ +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 { 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"; + +// Button calls a one-argument function with its icon colour, so the Apple mark follows the theme. +const renderAppleIcon = (color: string) => ; +const renderGooglePlayIcon = () => ; + +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 [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( + () => ( + + ), + [t], + ); + const docsLink = useMemo( + () => ({ + href: CONNECTIVITY_DOCS_URL, + label: t("onboarding.setup.steps.phone.connectDocs"), + accessibilityLabel: t("onboarding.setup.steps.phone.openConnectDocs"), + }), + [t], + ); + + return ( + + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + gap: theme.spacing[6], + }, + storeLinks: { + flexDirection: "row", + flexWrap: "wrap", + gap: theme.spacing[2], + }, +})); diff --git a/packages/app/src/onboarding/internal/steps/projects-step.tsx b/packages/app/src/onboarding/internal/steps/projects-step.tsx new file mode 100644 index 0000000000..9f64f6d839 --- /dev/null +++ b/packages/app/src/onboarding/internal/steps/projects-step.tsx @@ -0,0 +1,109 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Check, FolderPlus } from "lucide-react-native"; +import { Button } from "@/components/ui/button"; +import { useOpenAddProject } from "@/hooks/use-open-add-project"; +import { + FolderProjectIcon, + ProjectSuggestionList, + type ProjectSuggestionsController, +} from "@/project-suggestions"; +import { useProjectRootPaths } from "@/stores/session-store-hooks"; +import { settingsStyles } from "@/styles/settings"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; + +const ThemedCheck = withUnistyles(Check); +const successMapping = (theme: Theme) => ({ color: theme.colors.palette.green[400] }); + +function basename(path: string): string { + const segments = path.split(/[\\/]/).filter((segment) => segment.length > 0); + return segments.at(-1) ?? path; +} + +/** Adding the checked suggestions is the setup's primary action; see the modal footer. */ +export function ProjectsStep({ + serverId, + suggestions, +}: { + serverId: string; + suggestions: ProjectSuggestionsController; +}) { + const { t } = useTranslation(); + const openAddProject = useOpenAddProject(); + const projectRootPaths = useProjectRootPaths(serverId); + const handleAddProject = useCallback(() => openAddProject(serverId), [openAddProject, serverId]); + + return ( + + + + + {projectRootPaths.length > 0 ? ( + + {projectRootPaths.map((rootPath, index) => ( + 0 && settingsStyles.rowBorder]} + > + + + + + {basename(rootPath)} + + + + {rootPath} + + + + + {t("onboarding.setup.steps.projects.added")} + + + ))} + + ) : null} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + gap: theme.spacing[4], + }, + actions: { + flexDirection: "row", + 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", + gap: theme.spacing[1], + }, + addedLabel: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, +})); diff --git a/packages/app/src/onboarding/internal/steps/providers-step.tsx b/packages/app/src/onboarding/internal/steps/providers-step.tsx new file mode 100644 index 0000000000..84235fe921 --- /dev/null +++ b/packages/app/src/onboarding/internal/steps/providers-step.tsx @@ -0,0 +1,10 @@ +import { View } from "react-native"; +import { ProvidersSection } from "@/screens/settings/providers-section"; + +export function ProvidersStep({ serverId }: { serverId: string }) { + return ( + + + + ); +} diff --git a/packages/app/src/onboarding/internal/use-setup-host.ts b/packages/app/src/onboarding/internal/use-setup-host.ts new file mode 100644 index 0000000000..0680e8a466 --- /dev/null +++ b/packages/app/src/onboarding/internal/use-setup-host.ts @@ -0,0 +1,14 @@ +import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon"; +import { useHosts } from "@/runtime/host-runtime"; + +/** + * The host setup configures: the desktop's built-in daemon, otherwise the first saved host. + * Null while the desktop daemon is still reporting its id, or when there is no host at all. + */ +export function useSetupHostServerId(): string | null { + const localDaemon = useLocalDaemonServerIdState(); + const hosts = useHosts(); + if (localDaemon.status === "loading") return null; + if (localDaemon.status === "resolved" && localDaemon.serverId) return localDaemon.serverId; + return hosts[0]?.serverId ?? null; +} diff --git a/packages/app/src/onboarding/onboarding-host.tsx b/packages/app/src/onboarding/onboarding-host.tsx new file mode 100644 index 0000000000..28280c7c70 --- /dev/null +++ b/packages/app/src/onboarding/onboarding-host.tsx @@ -0,0 +1,40 @@ +import { useEffect, useSyncExternalStore } from "react"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { isNative } from "@/constants/platform"; +import { useDirectoryDemand } from "@/hooks/use-directory-demand"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useHasHydratedWorkspaces, useProjectRootPaths } from "@/stores/session-store-hooks"; +import { getOnboardingModel } from "./internal/onboarding-instance"; +import { OnboardingModal } from "./internal/onboarding-modal"; +import { useSetupHostServerId } from "./internal/use-setup-host"; + +/** Mounted once by the app shell. Decides whether setup opens, then owns the modal. */ +export function OnboardingHost() { + // Native phones are set up from a desktop; setup never opens there. + if (isNative) return null; + return ; +} + +function OnboardingGate() { + const model = getOnboardingModel(); + const state = useSyncExternalStore(model.subscribe, model.getState, model.getState); + const serverId = useSetupHostServerId(); + const isConnected = useHostRuntimeIsConnected(serverId ?? ""); + const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId); + const projectRootPaths = useProjectRootPaths(serverId); + const isCompact = useIsCompactFormFactor(); + const hasProjects = projectRootPaths.length > 0; + + // Without demand nothing may ask a fresh host for its workspaces, and the gate would wait forever. + useDirectoryDemand(state.status === "hidden" ? null : serverId); + + useEffect(() => { + model.applySetupHost( + serverId ? { serverId, isConnected, hasHydratedWorkspaces, hasProjects } : null, + { includePhoneStep: !isCompact }, + ); + }, [model, serverId, isConnected, hasHydratedWorkspaces, hasProjects, isCompact]); + + if (state.status !== "open") return null; + return ; +} diff --git a/packages/app/src/onboarding/use-open-onboarding.ts b/packages/app/src/onboarding/use-open-onboarding.ts new file mode 100644 index 0000000000..1f4a93328e --- /dev/null +++ b/packages/app/src/onboarding/use-open-onboarding.ts @@ -0,0 +1,15 @@ +import { useCallback } from "react"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { getOnboardingModel } from "./internal/onboarding-instance"; +import { useSetupHostServerId } from "./internal/use-setup-host"; + +/** Reopens setup on its first step. Null when there is no host to set up. */ +export function useOpenOnboarding(): (() => void) | null { + const serverId = useSetupHostServerId(); + const isCompact = useIsCompactFormFactor(); + const open = useCallback(() => { + if (!serverId) return; + getOnboardingModel().reopen(serverId, { includePhoneStep: !isCompact }); + }, [isCompact, serverId]); + return serverId ? open : null; +} diff --git a/packages/app/src/pane-find/index.tsx b/packages/app/src/pane-find/index.tsx index 761ac187e1..f1ee6cfb69 100644 --- a/packages/app/src/pane-find/index.tsx +++ b/packages/app/src/pane-find/index.tsx @@ -145,8 +145,6 @@ export const PaneFind = forwardRef(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(null); const replacementInput = useRef(null); useLayoutEffect(() => { @@ -293,7 +291,7 @@ export const PaneFind = forwardRef(function PaneF /> @@ -1341,7 +1341,6 @@ function TimelineSyncErrorCallout({ {t("agentPanel.states.timelineSyncFailed")} ) : null} diff --git a/packages/app/src/plugins/settings/index.tsx b/packages/app/src/plugins/settings/index.tsx index 3399f241ac..b5b4f20909 100644 --- a/packages/app/src/plugins/settings/index.tsx +++ b/packages/app/src/plugins/settings/index.tsx @@ -85,7 +85,7 @@ function SettingsContent({ (error: string) => ( {error} - @@ -141,7 +141,6 @@ export function PluginSettingsContent({ @@ -495,7 +495,7 @@ export function InlineReviewEditor({ disabled={!canSave} onPress={handleSave} variant="default" - size="xs" + size="sm" > {t("review.comment.save")} diff --git a/packages/app/src/screens/new-workspace/form/field-trigger.tsx b/packages/app/src/screens/new-workspace/form/field-trigger.tsx index c82cadb14a..8c0be791dc 100644 --- a/packages/app/src/screens/new-workspace/form/field-trigger.tsx +++ b/packages/app/src/screens/new-workspace/form/field-trigger.tsx @@ -135,7 +135,7 @@ export const FieldTrigger = forwardRef(function FieldTr }); // Height of a badge. The desktop row sizes from it. -export const FIELD_BADGE_HEIGHT = CONTROL_HEIGHTS.tight; +export const FIELD_BADGE_HEIGHT = CONTROL_HEIGHTS.sm; // Height of a compact field: label line plus value line. The compact stack reserves rows from it. export const FIELD_HEIGHT = 56; diff --git a/packages/app/src/screens/new-workspace/import-session-button.tsx b/packages/app/src/screens/new-workspace/import-session-button.tsx index 39d4efc1ce..3114d7bd79 100644 --- a/packages/app/src/screens/new-workspace/import-session-button.tsx +++ b/packages/app/src/screens/new-workspace/import-session-button.tsx @@ -21,7 +21,6 @@ export function ImportSessionButton({ @@ -177,7 +175,6 @@ function BackToProjectsButton({ onPress }: { onPress: () => void }) { accessibilityLabel={t("settings.project.backToProjects")} onPress={onPress} variant="ghost" - size="sm" leftIcon={ArrowLeft} style={styles.backButton} > @@ -420,7 +417,7 @@ function ReadFailureCallout({ kind, error, onReload }: ReadFailureCalloutProps) return ( - @@ -662,25 +659,21 @@ function ProjectConfigForm({ ); const setupDocsLink = useMemo( - () => ( - - ), + () => ({ + href: WORKTREE_DOCS_URL, + label: t("settings.project.worktree.docs"), + tooltip: t("settings.project.worktree.docsTooltip"), + testID: "worktree-setup-docs-link", + }), [t], ); const teardownDocsLink = useMemo( - () => ( - - ), + () => ({ + href: WORKTREE_DOCS_URL, + label: t("settings.project.worktree.docs"), + tooltip: t("settings.project.worktree.docsTooltip"), + testID: "worktree-teardown-docs-link", + }), [t], ); @@ -698,7 +691,7 @@ function ProjectConfigForm({ {hasUncommittedWorktreeSetupChanges ? ( - @@ -804,20 +792,10 @@ function ProjectConfigForm({ title={t("settings.project.writeFailures.failedTitle")} description={t("settings.project.writeFailures.failedDescription")} > - - @@ -829,7 +807,6 @@ function ProjectConfigForm({ testID="save-button" accessibilityLabel={t("settings.project.actions.save")} variant="default" - size="md" disabled={saveDisabled} loading={saveMutation.isPending} onPress={handleSave} @@ -1106,10 +1083,10 @@ function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModal - - diff --git a/packages/app/src/screens/schedules-screen.tsx b/packages/app/src/screens/schedules-screen.tsx index 3c76db14f2..ff18c4788d 100644 --- a/packages/app/src/screens/schedules-screen.tsx +++ b/packages/app/src/screens/schedules-screen.tsx @@ -277,20 +277,13 @@ function SchedulesScreenBody({ /> ) : null} - diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 9c2ee93061..d7c0ae1b8f 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -142,7 +142,8 @@ import { useSettingsSidebarScroll, } from "@/navigation/settings-presentation"; import { returnFromSettings, type SettingsView } from "@/navigation/settings-navigation"; -import { isWeb } from "@/constants/platform"; +import { isNative, isWeb } from "@/constants/platform"; +import { useOpenOnboarding } from "@/onboarding"; // --------------------------------------------------------------------------- // View model @@ -386,11 +387,28 @@ function GeneralSection({ settings, handleLanguageChange }: GeneralSectionProps) + {isNative ? null : } ); } +function ShowSetupAgainRow() { + const { t } = useTranslation(); + const openOnboarding = useOpenOnboarding(); + return ( + + + {t("settings.general.setup.label")} + {t("settings.general.setup.description")} + + + + ); +} + interface DiagnosticsSectionProps { voiceAudioEngine: ReturnType; isPlaybackTestRunning: boolean; @@ -417,7 +435,7 @@ function DiagnosticsSection({ {t("settings.diagnostics.app.rowTitle")} {t("settings.diagnostics.app.rowHint")} - @@ -430,7 +448,6 @@ function DiagnosticsSection({ @@ -672,7 +666,6 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { )} {isBindable && ( - )} @@ -447,7 +447,7 @@ export function KeyboardShortcutsSection() { } const resetAllButton = hasOverrides ? ( - ) : undefined; diff --git a/packages/app/src/screens/settings/metadata-generation-page.tsx b/packages/app/src/screens/settings/metadata-generation-page.tsx index c9a04e9b3e..0e8d895228 100644 --- a/packages/app/src/screens/settings/metadata-generation-page.tsx +++ b/packages/app/src/screens/settings/metadata-generation-page.tsx @@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import { CombinedModelSelector } from "@/components/combined-model-selector"; -import { ExternalLink } from "@/components/ui/external-link"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; @@ -90,12 +89,7 @@ export function MetadataGenerationPage({ serverId }: { serverId: string }) { [snapshot], ); const docsLink = useMemo( - () => ( - - ), + () => ({ href: METADATA_GENERATION_DOCS_URL, label: t("settings.metadataGeneration.docs") }), [t], ); @@ -111,7 +105,7 @@ export function MetadataGenerationPage({ serverId }: { serverId: string }) { @@ -130,7 +124,6 @@ export function MetadataGenerationPage({ serverId }: { serverId: string }) { options={modeOptions} value={mode} onValueChange={handleModeChange} - size="sm" testID="metadata-generation-mode" /> diff --git a/packages/app/src/screens/settings/paseo-tools-card.tsx b/packages/app/src/screens/settings/paseo-tools-card.tsx new file mode 100644 index 0000000000..df36bac97b --- /dev/null +++ b/packages/app/src/screens/settings/paseo-tools-card.tsx @@ -0,0 +1,56 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { SettingsCard, SettingsRow } from "@/components/settings"; +import { Switch } from "@/components/ui/switch"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; + +/** The host's "give agents Paseo tools" switch, bound to the host's config. */ +export function PaseoToolsCard({ serverId }: { serverId: string }) { + const isConnected = useHostRuntimeIsConnected(serverId); + const { config, patchConfig } = useDaemonConfig(serverId); + + const handleValueChange = useCallback( + (next: boolean) => { + void patchConfig({ mcp: { injectIntoAgents: next } }); + }, + [patchConfig], + ); + + if (!isConnected) return null; + + return ( + + + + ); +} + +/** The Paseo tools row on its own, for a caller that decides what the switch is bound to. */ +export function PaseoToolsRow({ + value, + onValueChange, + disabled, +}: { + value: boolean; + onValueChange: (next: boolean) => void; + disabled?: boolean; +}) { + const { t } = useTranslation(); + return ( + + + + ); +} diff --git a/packages/app/src/screens/settings/plugins-page.tsx b/packages/app/src/screens/settings/plugins-page.tsx index f3bdab3fad..ae7a285fac 100644 --- a/packages/app/src/screens/settings/plugins-page.tsx +++ b/packages/app/src/screens/settings/plugins-page.tsx @@ -216,7 +216,7 @@ function PluginLogsSheet({ () => ({ title: t("settings.plugins.logs.title", { id: pluginId }), actions: ( - @@ -433,7 +433,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { title={t("settings.plugins.states.errorTitle")} description={errorMessage(plugins.error)} > - diff --git a/packages/app/src/screens/settings/providers-section.test.tsx b/packages/app/src/screens/settings/providers-section.test.tsx index 0a702ae641..86c55be4e8 100644 --- a/packages/app/src/screens/settings/providers-section.test.tsx +++ b/packages/app/src/screens/settings/providers-section.test.tsx @@ -171,6 +171,11 @@ vi.mock("@/components/settings/headings/settings-info-tip", () => ({ SettingsInfoTip: () => null, })); +vi.mock("@/components/ui/external-link", () => ({ + ExternalLink: () => null, + ExternalLinkLabel: () => null, +})); + vi.mock("@/components/ui/dropdown-menu", () => ({ DropdownMenu: ({ children }: { children?: React.ReactNode }) => React.createElement("div", null, children), diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index 72234b7602..09b09586d3 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -26,6 +26,7 @@ import { import { ProviderCatalogList } from "@/components/provider-catalog-list"; import { useProviderIcon } from "@/components/provider-icons"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { DropdownMenu, @@ -169,6 +170,33 @@ function ProviderActionsMenu({ ); } +const SKELETON_ROW_KEYS = [0, 1, 2, 3].map((index) => `provider-skeleton-row-${index}`); + +function ProviderRowsSkeleton() { + const { t } = useTranslation(); + const pulse = useSkeletonPulse(); + return ( + + {SKELETON_ROW_KEYS.map((key, index) => ( + 0 && settingsStyles.rowBorder, styles.row]} + > + + + + + + + ))} + + ); +} + function ProviderRow({ serverId, def, @@ -246,27 +274,27 @@ function ProviderRow({ ) : null} + {/* The menu leads the switch, so every switch sits on the row's right edge without + rows that have no menu reserving room for one. */} + {canRemove ? ( + + ) : null} - - {canRemove ? ( - - ) : null} - )} @@ -439,11 +467,7 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { {t("settings.providers.unavailable")} ) : null} - {hasServer && isConnected && isLoading ? ( - - {t("settings.providers.loading")} - - ) : null} + {hasServer && isConnected && isLoading ? : null} {hasServer && isConnected && !isLoading && providerDefinitions.length > 0 ? ( {providerDefinitions.map((def, index) => { @@ -513,6 +537,25 @@ const styles = StyleSheet.create((theme) => ({ rowHovered: { backgroundColor: theme.colors.surface2, }, + skeletonIcon: { + width: 20, + height: 20, + marginLeft: theme.spacing[6], + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface2, + }, + skeletonTitle: { + width: 160, + height: 12, + borderRadius: theme.borderRadius.sm, + backgroundColor: theme.colors.surface2, + }, + skeletonSwitch: { + width: 36, + height: 20, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface2, + }, rowPressed: { backgroundColor: theme.colors.surface3, }, @@ -566,10 +609,6 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "center", }, - menuSlot: { - width: 32, - height: 32, - }, menuButtonHovered: { backgroundColor: theme.colors.surface2, }, diff --git a/packages/app/src/screens/settings/restart-recovery-card.tsx b/packages/app/src/screens/settings/restart-recovery-card.tsx index 8d9b61fbee..9c6d7a6d7a 100644 --- a/packages/app/src/screens/settings/restart-recovery-card.tsx +++ b/packages/app/src/screens/settings/restart-recovery-card.tsx @@ -87,7 +87,6 @@ export function RestartRecoveryCard({ serverId }: { serverId: string }) { - @@ -245,10 +244,10 @@ function WorkspaceUnreachable({ {canRetry ? ( - - @@ -277,7 +276,7 @@ function WorkspaceEmptyState({ {description ?? hostName} - diff --git a/packages/app/src/stores/session-store-hooks/index.ts b/packages/app/src/stores/session-store-hooks/index.ts index 3dba13bc80..d6d4a188c1 100644 --- a/packages/app/src/stores/session-store-hooks/index.ts +++ b/packages/app/src/stores/session-store-hooks/index.ts @@ -10,6 +10,7 @@ import { selectWorkspaceDirectoryServerIds, selectHasWorkspaces, selectProjectOrder, + selectProjectRootPaths, selectRecommendedProjectPaths, selectWorkspace, selectWorkspaceDirectory, @@ -147,6 +148,14 @@ export function useRecommendedProjectPaths(serverId: string | null): string[] { ); } +export function useProjectRootPaths(serverId: string | null): string[] { + return useStoreWithEqualityFn( + useSessionStore, + (state) => selectProjectRootPaths(state, serverId), + workspaceEqualityFns.deep, + ); +} + export function useHasWorkspaces(serverId: string | null): boolean { return useStoreWithEqualityFn( useSessionStore, diff --git a/packages/app/src/stores/session-store-hooks/selectors.ts b/packages/app/src/stores/session-store-hooks/selectors.ts index c131fa7a3a..85169b6f4a 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.ts @@ -307,6 +307,14 @@ export function selectRecommendedProjectPaths( .filter((path) => path.length > 0); } +export function selectProjectRootPaths(state: SessionsSnapshot, serverId: string | null): string[] { + const projects = serverId ? state.sessions[serverId]?.projects : undefined; + if (!projects || projects.size === 0) { + return EMPTY_WORKSPACE_KEYS; + } + return Array.from(projects.values(), (project) => project.projectRootPath); +} + export function selectHasWorkspaces(state: SessionsSnapshot, serverId: string | null): boolean { if (!serverId) { return false; diff --git a/packages/app/src/styles/settings.ts b/packages/app/src/styles/settings.ts index 4f6d377b38..4fa4dcd22d 100644 --- a/packages/app/src/styles/settings.ts +++ b/packages/app/src/styles/settings.ts @@ -21,10 +21,6 @@ export const settingsStyles = StyleSheet.create((theme) => ({ flexDirection: "row", gap: theme.spacing[1], }, - sectionHeaderLinkText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, card: { backgroundColor: theme.colors.surface1, borderRadius: theme.borderRadius.lg, diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 977a382c41..9b941543fe 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -550,7 +550,7 @@ const ghosttyDarkColors = buildDarkSemanticColors({ foregroundExtraMuted: "#a0a4b2", border: "#353a47", borderAccent: "#3f4454", - accent: "#89b4fa", + accent: "#3570e0", // dark enough to carry white text on filled buttons accentBright: "#b4d0fc", destructive: "#c44a55", // red with slight cool lean against the slate-blue surfaces terminalBlack: "#21252d", @@ -564,8 +564,10 @@ export const SPACING = { 1.5: 6, 2: 8, 3: 12, + 3.5: 14, 4: 16, 6: 24, + 7: 28, 8: 32, 12: 48, 16: 64, diff --git a/packages/app/src/subagents/parent/callout.tsx b/packages/app/src/subagents/parent/callout.tsx index 7665c0d12a..d1bb31204a 100644 --- a/packages/app/src/subagents/parent/callout.tsx +++ b/packages/app/src/subagents/parent/callout.tsx @@ -46,7 +46,6 @@ export function ParentAgentCallout({ {parent.label ?? t("subagents.parent.parentAgent")} diff --git a/packages/app/src/workspace-labels/manager-modal.tsx b/packages/app/src/workspace-labels/manager-modal.tsx index a23e7e0925..8b0905dd16 100644 --- a/packages/app/src/workspace-labels/manager-modal.tsx +++ b/packages/app/src/workspace-labels/manager-modal.tsx @@ -17,7 +17,7 @@ import { import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { HostFilter } from "@/components/hosts/host-filter"; 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 { isNative } from "@/constants/platform"; @@ -146,7 +146,6 @@ export function WorkspaceLabelManagerModal({