From d95e46ed9f8c7941ee3d4f5b18c9c52018abac41 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Nov 2026 17:07:23 +0100 Subject: [PATCH] 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. --- docs/design.md | 18 +- packages/app/e2e/browser/onboarding.spec.ts | 44 +-- .../app/e2e/support/helpers/agent-skills.ts | 14 +- .../app/e2e/support/helpers/onboarding.ts | 38 +- .../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 | 293 +++++++--------- .../app/src/agent-skills/selection-sheet.tsx | 112 ++---- .../app/src/agent-skills/use-agent-skills.ts | 25 +- .../src/agent-stream/chat-find/index.web.tsx | 2 +- packages/app/src/appearance/theme-picker.tsx | 60 ++-- .../changelog/internal/changelog-sheet.tsx | 2 +- .../src/components/adaptive-modal-sheet.tsx | 46 ++- .../components/add-remote-ssh-host-modal.tsx | 24 +- .../src/components/archived-agent-callout.tsx | 1 - .../app/src/components/community-links.tsx | 3 - .../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 ++- .../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 +- .../app/src/components/ui/switch.test.tsx | 1 + packages/app/src/components/ui/switch.tsx | 65 ++-- .../app/src/components/user-message/index.tsx | 2 - .../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 | 12 +- .../app/src/desktop/daemon/desktop-daemon.ts | 4 + .../src/desktop/hooks/use-install-status.ts | 50 ++- 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 +- .../git/pull-request-panel/checks-section.tsx | 2 +- .../src/git/pull-request-panel/pane-error.tsx | 2 +- .../app/src/git/pull-request-panel/pane.tsx | 12 +- .../git/pull-request-panel/section-kit.tsx | 4 +- .../app/src/hosts/host-confirmation-sheet.tsx | 2 - packages/app/src/i18n/resources/ar.ts | 66 +--- packages/app/src/i18n/resources/en.ts | 67 +--- packages/app/src/i18n/resources/es.ts | 69 +--- packages/app/src/i18n/resources/fr.ts | 68 +--- packages/app/src/i18n/resources/ja.ts | 67 +--- packages/app/src/i18n/resources/ko.ts | 67 +--- packages/app/src/i18n/resources/pt-BR.ts | 66 +--- packages/app/src/i18n/resources/ru.ts | 67 +--- packages/app/src/i18n/resources/zh-CN.ts | 64 +--- .../agent-route-resolution-view.tsx | 8 +- .../onboarding/internal/onboarding-modal.tsx | 142 +++++--- .../internal/orchestration-setup.ts | 83 +++++ .../src/onboarding/internal/steps/index.tsx | 9 +- .../internal/steps/orchestration-step.tsx | 103 ++---- .../onboarding/internal/steps/phone-step.tsx | 84 ++++- .../internal/steps/projects-step.tsx | 48 ++- 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 | 1 + .../internal/folder-project-icon.tsx | 39 +++ .../internal/project-suggestions.tsx | 328 +++++++----------- .../internal/suggestions.test.ts | 32 +- .../internal/suggestions.ts | 25 +- .../internal/use-project-suggestions.ts | 21 +- .../internal/use-provider-suggestions.ts | 45 +-- 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 | 13 +- .../appearance/appearance-section.tsx | 1 - .../src/screens/settings/files-section.tsx | 1 - .../app/src/screens/settings/host-page.tsx | 23 +- .../settings/keyboard-shortcuts-section.tsx | 6 +- .../settings/metadata-generation-page.tsx | 11 +- .../src/screens/settings/paseo-tools-card.tsx | 61 ++-- .../app/src/screens/settings/plugins-page.tsx | 6 +- .../settings/providers-section.test.tsx | 5 + .../screens/settings/providers-section.tsx | 37 +- .../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 +- 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/desktop/src/daemon/daemon-manager.ts | 2 + .../src/integrations/cli-install/index.ts | 2 +- .../src/integrations/cli-install/install.ts | 82 +++-- 133 files changed, 1808 insertions(+), 2019 deletions(-) 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/onboarding/internal/orchestration-setup.ts create mode 100644 packages/app/src/project-suggestions/internal/folder-project-icon.tsx diff --git a/docs/design.md b/docs/design.md index a336b80a64..dcc5051f19 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..6cc27f55cf 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,49 @@ function formatUpdateMessage(ops: readonly AgentSkillOperation[], t: TFunction): .join("\n"); } +/** Whether the host has Paseo's skills installed in any form, current or not. */ +export function areSkillsInstalled(status: AgentSkillsStatus | null): boolean { + return status !== null && status.state !== "not-installed"; +} + +function hasSelectedSkills(status: AgentSkillsStatus | null): boolean { + if (!status) return false; + if (status.selection.mode === "all") return status.available.length > 0; + return status.selection.skills.some((name) => status.available.includes(name)); +} + export function AgentSkillsSection({ serverId }: { serverId: string }) { const { t } = useTranslation(); const skills = useAgentSkills(serverId); - const refreshSkills = skills.refresh; - const skillsConnected = skills.connected; - const skillsSupported = skills.supported; - const [isChoosing, setIsChoosing] = useState(false); + const { refresh, connected, supported, reconcile, uninstall } = skills; useFocusEffect( useCallback(() => { - if (skillsConnected && skillsSupported) void refreshSkills(); + if (connected && supported) void refresh(); return undefined; - }, [refreshSkills, skillsConnected, skillsSupported]), + }, [refresh, connected, supported]), ); - const maintenanceOps = useMemo( - () => skills.status?.ops.filter((op) => op.kind !== "delete") ?? [], - [skills.status?.ops], + const handleValueChange = useCallback( + (next: boolean) => { + void (next ? reconcile() : uninstall()); + }, + [reconcile, uninstall], ); - const state = - skills.status?.state === "drift" && maintenanceOps.length === 0 - ? "up-to-date" - : (skills.status?.state ?? null); - const hasSelected = - (skills.status?.selection.mode === "all" && skills.status.available.length > 0) || - (skills.status?.selection.mode === "custom" && - skills.status.selection.skills.some((name) => skills.status?.available.includes(name))); - - const handleReconcile = useCallback(async () => { - if (skills.isWorking) return; - const confirmed = await confirmDialog({ - title: t("settings.host.skills.updateTitle"), - message: - maintenanceOps.length > 0 - ? formatUpdateMessage(maintenanceOps, t) - : t("settings.host.skills.updateFallback"), - confirmLabel: t("settings.host.skills.actions.update"), - }); - if (confirmed) await skills.reconcile(); - }, [maintenanceOps, skills, t]); - const handleUninstall = useCallback(async () => { - if (skills.isWorking) return; - const confirmed = await confirmDialog({ - title: t("settings.host.skills.uninstallTitle"), - message: t("settings.host.skills.uninstallMessage"), - confirmLabel: t("settings.host.skills.actions.uninstall"), - destructive: true, - }); - if (confirmed) await skills.uninstall(); - }, [skills, t]); - const chooseIcon = useMemo( - () => , - [], - ); - 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 +86,108 @@ 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/appearance/theme-picker.tsx b/packages/app/src/appearance/theme-picker.tsx index b6d393782a..cb2ebf5983 100644 --- a/packages/app/src/appearance/theme-picker.tsx +++ b/packages/app/src/appearance/theme-picker.tsx @@ -22,8 +22,7 @@ const PICKER_THEME_OPTIONS = [ ...THEME_OPTIONS.filter((option) => option.name !== "auto"), ]; -const PREVIEW_WIDTH = 128; -const PREVIEW_HEIGHT = 72; +const PREVIEW_ASPECT_RATIO = 16 / 9; /** * Every theme as a tile with a small preview drawn from its own tokens. Picking a tile applies @@ -134,21 +133,27 @@ function ThemeTile({ [selected], ); return ( - - - {previewTheme === "system" ? : } - - - {label} - - + + + + {previewTheme === "system" ? ( + + ) : ( + + )} + + + {label} + + + ); } @@ -197,13 +202,18 @@ function SystemThemePreview() { } const styles = StyleSheet.create((theme) => ({ + // Columns fill the row: each cell is a fixed fraction of it, and the half-gap padding on every + // cell plus the negative margin here leaves exactly one gap between neighbors. grid: { flexDirection: "row", flexWrap: "wrap", - gap: theme.spacing[3], + margin: -theme.spacing[3] / 2, + }, + cell: { + width: { xs: "50%", md: "25%" }, + padding: theme.spacing[3] / 2, }, tile: { - width: PREVIEW_WIDTH + theme.spacing[1] * 2 + theme.borderWidth[2] * 2, padding: theme.spacing[1], gap: theme.spacing[1], borderRadius: theme.borderRadius.lg, @@ -228,8 +238,8 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[1], }, preview: { - width: PREVIEW_WIDTH, - height: PREVIEW_HEIGHT, + width: "100%", + aspectRatio: PREVIEW_ASPECT_RATIO, flexDirection: "row", overflow: "hidden", }, @@ -270,11 +280,13 @@ const styles = StyleSheet.create((theme) => ({ position: "absolute", top: 0, bottom: 0, - left: PREVIEW_WIDTH / 2, - width: PREVIEW_WIDTH / 2, + left: "50%", + width: "50%", overflow: "hidden", }, + // A full-width dark preview shifted left by half, so only its right half shows. systemDarkOffset: { - marginLeft: -PREVIEW_WIDTH / 2, + width: "200%", + marginLeft: "-100%", }, })); 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/add-remote-ssh-host-modal.tsx b/packages/app/src/components/add-remote-ssh-host-modal.tsx index c7e25bce64..d9a285fa2a 100644 --- a/packages/app/src/components/add-remote-ssh-host-modal.tsx +++ b/packages/app/src/components/add-remote-ssh-host-modal.tsx @@ -21,7 +21,6 @@ import { resolveControlInteractionStyles, } from "@/components/ui/control-geometry"; import type { EditingTextInputHandle } from "@/components/ui/text-input"; -import { useIsCompactFormFactor } from "@/constants/layout"; import { DaemonConnectionTestError } from "@/utils/test-daemon-connection"; import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet"; @@ -49,18 +48,10 @@ const styles = StyleSheet.create((theme) => { minWidth: 0, }, // Boxed adornment next to the field: same height and radius as the field - // chrome of the active form size, and the shared interaction phases. - iconButtonSm: { - minHeight: CONTROL_HEIGHTS.compact, - width: CONTROL_HEIGHTS.compact, - alignItems: "center", - justifyContent: "center", - borderRadius: theme.borderRadius.md, - backgroundColor: theme.colors.surface2, - }, - iconButtonMd: { - minHeight: CONTROL_HEIGHTS.field, - width: CONTROL_HEIGHTS.field, + // chrome, and the shared interaction phases. + iconButton: { + minHeight: CONTROL_HEIGHTS.md, + width: CONTROL_HEIGHTS.md, alignItems: "center", justifyContent: "center", borderRadius: theme.borderRadius.lg, @@ -106,7 +97,6 @@ export function AddRemoteSshHostModal({ }: AddRemoteSshHostModalProps) { const { t } = useTranslation(); const hosts = useHosts(); - const isCompact = useIsCompactFormFactor(); const { probeAndUpsertRemoteSshConnection } = useHostMutations(); const targetRef = useRef(""); const passwordRef = useRef(""); @@ -193,7 +183,7 @@ export function AddRemoteSshHostModal({ ( state: PressableStateCallbackType & { hovered?: boolean; focused?: boolean }, ): StyleProp => [ - isCompact ? styles.iconButtonMd : styles.iconButtonSm, + styles.iconButton, resolveControlInteractionStyles( { controlRest: styles.controlRest, @@ -209,7 +199,7 @@ export function AddRemoteSshHostModal({ }, ), ], - [isCompact, isSaving], + [isSaving], ); const handleSubmit = useCallback(() => void handleSave(), [handleSave]); @@ -228,7 +218,6 @@ export function AddRemoteSshHostModal({ > {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 b960222bb9..bb5c853aca 100644 --- a/packages/app/src/desktop/components/pair-device-section.tsx +++ b/packages/app/src/desktop/components/pair-device-section.tsx @@ -22,7 +22,8 @@ import { } from "@/components/ui/text-input"; const RELAY_DOCS_URL = "https://paseo.sh/docs/security"; -const TAILSCALE_DOCS_URL = "https://paseo.sh/docs/connectivity#tailscale"; +export const CONNECTIVITY_DOCS_URL = "https://paseo.sh/docs/connectivity"; +export const TAILSCALE_DOCS_URL = `${CONNECTIVITY_DOCS_URL}#tailscale`; const FLEX_ONE_STYLE = { flex: 1 } as const; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedShieldCheck = withUnistyles(ShieldCheck); @@ -170,7 +171,7 @@ function OfferLoadError({ message, onRetry }: { message: string; onRetry: () => const { t } = useTranslation(); return ( - @@ -262,12 +263,7 @@ function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) { accessibilityLabel={t("pairing.link.label")} /> - 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..1881630f12 100644 --- a/packages/app/src/desktop/hooks/use-install-status.ts +++ b/packages/app/src/desktop/hooks/use-install-status.ts @@ -5,6 +5,7 @@ import { getCliInstallStatus, installCli, shouldUseDesktopDaemon, + uninstallCli, type InstallStatus, } from "@/desktop/daemon/desktop-daemon"; import { @@ -14,60 +15,73 @@ import { const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const; -interface DesktopInstallHookResult { +interface CliInstall { + /** False outside the desktop app: the CLI installs on the machine running the app. */ + available: boolean; status: InstallStatus | null; isLoading: boolean; - isInstalling: boolean; - error: Error | null; - install: () => void; + isWorking: boolean; + /** Installs or removes the CLI; resolves to whether it worked. Failures are reported. */ + setInstalled: (installed: boolean) => Promise; refresh: () => void; } -export function useCliInstall(): DesktopInstallHookResult { +export function useCliInstall(): CliInstall { const { t } = useTranslation(); const queryClient = useQueryClient(); const reportError = useDesktopIpcErrorReporter(); - const enabled = shouldUseDesktopDaemon(); + const available = shouldUseDesktopDaemon(); const statusQuery = useQuery({ 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; + const setInstalled = useCallback( + (installed: boolean) => + mutateAsync(installed).then( + (status) => status.installed === installed, + () => false, + ), + [mutateAsync], + ); const refresh = useCallback(() => { void refetch(); }, [refetch]); return { + available, status: installStatus ?? null, isLoading, - isInstalling, - error: statusError ?? installError ?? null, - install, + isWorking: isPending, + setInstalled, refresh, }; } 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.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/internal/onboarding-modal.tsx b/packages/app/src/onboarding/internal/onboarding-modal.tsx index f292fda2c0..f5f05ec79d 100644 --- a/packages/app/src/onboarding/internal/onboarding-modal.tsx +++ b/packages/app/src/onboarding/internal/onboarding-modal.tsx @@ -3,7 +3,11 @@ import { useTranslation } from "react-i18next"; import { Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Check } from "lucide-react-native"; -import { AdaptiveModalSheet, FULL_SHEET_SNAP_POINTS } from "@/components/adaptive-modal-sheet"; +import { + AdaptiveModalSheet, + FULL_SHEET_SNAP_POINTS, + SheetFooter, +} from "@/components/adaptive-modal-sheet"; import { PaseoLogo } from "@/components/icons/paseo-logo"; import { Button } from "@/components/ui/button"; import { PressHighlight } from "@/components/ui/press-highlight"; @@ -12,6 +16,7 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { useProjectSuggestions, type ProjectSuggestionsController } from "@/project-suggestions"; import type { Theme } from "@/styles/theme"; import type { OnboardingModel, OnboardingState, OnboardingStepId } from "./model"; +import { useOrchestrationSetup, type OrchestrationSetupController } from "./orchestration-setup"; import { OnboardingStepContent } from "./steps"; type OpenState = Extract; @@ -29,6 +34,8 @@ export function OnboardingModal({ model, state }: { model: OnboardingModel; stat // the Projects step rarely waits. const projectSuggestions = useProjectSuggestions(state.serverId); const { addChecked } = projectSuggestions; + const orchestration = useOrchestrationSetup(state.serverId); + const { apply: applyOrchestration } = orchestration; const handleComplete = useCallback(() => { void model.complete(); @@ -41,36 +48,46 @@ export function OnboardingModal({ model, state }: { model: OnboardingModel; stat if (await addChecked()) model.next(visit); })(); }, [addChecked, model, visit]); + const handleApplyOrchestration = useCallback(() => { + void (async () => { + if (await applyOrchestration()) model.next(visit); + })(); + }, [applyOrchestration, model, visit]); const primary = usePrimaryAction({ step, isLast, projectSuggestions, + orchestration, onFinish: handleComplete, onNext: handleNext, onAddSuggestions: handleAddSuggestions, + onApplyOrchestration: handleApplyOrchestration, }); - const header = useMemo( + // The compact sheet stacks a title row, the step and the actions. The desktop card gives the + // step rail the full height instead: the brand tops the rail, the actions close the step pane, + // and the sheet floats its close button. + const compactHeader = useMemo( () => ({ title: t("onboarding.setup.title"), - leading: , - subtitle: isCompact ? ( + leading: , + subtitle: ( {t("onboarding.setup.stepCounter", { current: state.stepIndex + 1, total: state.steps.length, })} - ) : undefined, + ), }), - [isCompact, state.stepIndex, state.steps.length, t], + [state.stepIndex, state.steps.length, t], ); - const footer = ( + const actions = ( <> {isLast ? null : ( - )} @@ -92,8 +109,8 @@ export function OnboardingModal({ model, state }: { model: OnboardingModel; stat {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`)} - + + + {/* 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}} + ); @@ -129,18 +149,30 @@ export function OnboardingModal({ model, state }: { model: OnboardingModel; stat /** * The footer's main button. On Projects, with folders checked, it adds them and moves on only once - * every one was added; otherwise it continues, or finishes on the last step. + * every one was added. On Orchestration it applies the switches and moves on once they all took. + * Otherwise it continues, or finishes on the last step. */ function usePrimaryAction(input: { step: OnboardingStepId; isLast: boolean; projectSuggestions: ProjectSuggestionsController; + orchestration: OrchestrationSetupController; onFinish: () => void; onNext: () => void; onAddSuggestions: () => void; + onApplyOrchestration: () => void; }): { label: string; busy: boolean; onPress: () => void } { const { t } = useTranslation(); - const { step, isLast, projectSuggestions, onFinish, onNext, onAddSuggestions } = input; + const { + step, + isLast, + projectSuggestions, + orchestration, + onFinish, + onNext, + onAddSuggestions, + onApplyOrchestration, + } = input; const { checkedCount, isAdding } = projectSuggestions; if (isLast) { return { label: t("onboarding.setup.finish"), busy: false, onPress: onFinish }; @@ -155,6 +187,13 @@ function usePrimaryAction(input: { onPress: onAddSuggestions, }; } + if (step === "orchestration") { + return { + label: t("onboarding.setup.continue"), + busy: orchestration.isApplying, + onPress: onApplyOrchestration, + }; + } return { label: t("onboarding.setup.continue"), busy: false, onPress: onNext }; } @@ -168,6 +207,12 @@ function StepRail({ const { t } = useTranslation(); return ( + + + + {t("onboarding.setup.title")} + + {state.steps.map((step, index) => ( ))} - {t("onboarding.setup.footnote")} ); } @@ -234,6 +278,7 @@ function StepRailItem({ } const BADGE_SIZE = 20; +const LOGO_SIZE = 20; const styles = StyleSheet.create((theme) => ({ content: { @@ -248,11 +293,25 @@ const styles = StyleSheet.create((theme) => ({ rail: { width: 200, padding: theme.spacing[3], - justifyContent: "space-between", borderRightWidth: theme.borderWidth[1], borderRightColor: theme.colors.surface2, backgroundColor: theme.colors.surface1, }, + // Inset like a rail item, so the logo sits on the badges' column. + railBrand: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[2], + marginBottom: theme.spacing[4], + }, + railTitle: { + flexShrink: 1, + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.medium, + }, railSteps: { gap: theme.spacing[1], }, @@ -262,7 +321,7 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[2], - borderRadius: theme.borderRadius.md, + borderRadius: theme.borderRadius.full, }, railItemHovered: { backgroundColor: theme.colors.surface2, @@ -291,30 +350,29 @@ const styles = StyleSheet.create((theme) => ({ }, railBadgeText: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, + fontSize: theme.fontSize.base, }, railBadgeTextActive: { color: theme.colors.accentForeground, - fontSize: theme.fontSize.sm, + fontSize: theme.fontSize.base, }, railLabel: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, + fontSize: theme.fontSize.base, }, railLabelCurrent: { color: theme.colors.foreground, - fontSize: theme.fontSize.sm, + fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.medium, }, - railFootnote: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - paddingHorizontal: theme.spacing[2], - }, pane: { flex: 1, minHeight: 0, }, + paneScroll: { + flex: 1, + minHeight: 0, + }, paneContent: { padding: theme.spacing[6], gap: theme.spacing[6], 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..58c3970a7e --- /dev/null +++ b/packages/app/src/onboarding/internal/orchestration-setup.ts @@ -0,0 +1,83 @@ +import { useCallback, useState } from "react"; +import { areSkillsInstalled, useAgentSkills, type AgentSkillsController } from "@/agent-skills"; +import { useCliInstall } from "@/desktop/hooks/use-install-status"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; + +export type OrchestrationChoice = "tools" | "skills" | "cli"; +type Choices = Record; + +const ALL_ON: Choices = { tools: true, skills: true, cli: true }; + +export interface OrchestrationSetupController { + choices: Choices; + setChoice: (choice: OrchestrationChoice, on: boolean) => void; + /** Only the choices this host and app can act on: skills need host support, the CLI the desktop app. */ + offered: Choices; + /** The skills row's picker and Update act on the host directly, through this. */ + skills: AgentSkillsController; + isApplying: boolean; + /** Brings the host in line with the choices; resolves true only when every one took. */ + apply: () => Promise; +} + +/** + * The Orchestration step's choices. Everything starts on and nothing touches the host or this + * computer until Continue applies them, the way checked projects are added. + */ +export function useOrchestrationSetup(serverId: string): OrchestrationSetupController { + const skills = useAgentSkills(serverId); + const cli = useCliInstall(); + const { config, patchConfig } = useDaemonConfig(serverId); + const [choices, setChoices] = useState(ALL_ON); + const [isApplying, setIsApplying] = useState(false); + const offered: Choices = { + tools: true, + skills: skills.connected && skills.supported, + cli: cli.available, + }; + + const setChoice = useCallback((choice: OrchestrationChoice, on: boolean) => { + setChoices((current) => ({ ...current, [choice]: on })); + }, []); + + const toolsOn = config?.mcp.injectIntoAgents !== false; + const skillsInstalled = areSkillsInstalled(skills.status); + const cliInstalled = cli.status?.installed ?? false; + const { reconcile, uninstall } = skills; + const { setInstalled: setCliInstalled } = cli; + const offersSkills = offered.skills; + const offersCli = offered.cli; + + const setSkillsInstalled = useCallback( + () => (choices.skills ? reconcile() : uninstall()), + [choices.skills, reconcile, uninstall], + ); + + const apply = useCallback(async () => { + setIsApplying(true); + try { + const results = await Promise.all([ + choices.tools === toolsOn + ? true + : patchConfig({ mcp: { injectIntoAgents: choices.tools } }).then(Boolean, () => false), + !offersSkills || choices.skills === skillsInstalled ? true : setSkillsInstalled(), + !offersCli || choices.cli === cliInstalled ? true : setCliInstalled(choices.cli), + ]); + return results.every(Boolean); + } finally { + setIsApplying(false); + } + }, [ + choices, + toolsOn, + patchConfig, + offersSkills, + skillsInstalled, + setSkillsInstalled, + offersCli, + cliInstalled, + setCliInstalled, + ]); + + return { choices, setChoice, offered, skills, isApplying, apply }; +} diff --git a/packages/app/src/onboarding/internal/steps/index.tsx b/packages/app/src/onboarding/internal/steps/index.tsx index adbf17e49c..ca1fd9060a 100644 --- a/packages/app/src/onboarding/internal/steps/index.tsx +++ b/packages/app/src/onboarding/internal/steps/index.tsx @@ -1,5 +1,6 @@ import type { ProjectSuggestionsController } from "@/project-suggestions"; import type { OnboardingStepId } from "../model"; +import type { OrchestrationSetupController } from "../orchestration-setup"; import { AppearanceStep } from "./appearance-step"; import { OrchestrationStep } from "./orchestration-step"; import { PhoneStep } from "./phone-step"; @@ -10,12 +11,12 @@ export function OnboardingStepContent({ step, serverId, projectSuggestions, - onFinish, + orchestration, }: { step: OnboardingStepId; serverId: string; projectSuggestions: ProjectSuggestionsController; - onFinish: () => void; + orchestration: OrchestrationSetupController; }) { switch (step) { case "appearance": @@ -25,8 +26,8 @@ export function OnboardingStepContent({ case "projects": return ; case "orchestration": - return ; + return ; case "phone": - return ; + return ; } } diff --git a/packages/app/src/onboarding/internal/steps/orchestration-step.tsx b/packages/app/src/onboarding/internal/steps/orchestration-step.tsx index 013bee28a8..da2249b0cd 100644 --- a/packages/app/src/onboarding/internal/steps/orchestration-step.tsx +++ b/packages/app/src/onboarding/internal/steps/orchestration-step.tsx @@ -1,86 +1,25 @@ -import { useTranslation } from "react-i18next"; -import { Text, View } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { CalendarClock, GitBranch, SquareTerminal, Users } from "lucide-react-native"; -import { PaseoToolsCard } from "@/screens/settings/paseo-tools-card"; -import type { Theme } from "@/styles/theme"; +import { useCallback } from "react"; +import { AgentSkillsRow } from "@/agent-skills"; +import { SettingsCard } from "@/components/settings"; +import { CommandLineRow } from "@/desktop/components/integrations-section"; +import { PaseoToolsRow } from "@/screens/settings/paseo-tools-card"; +import type { OrchestrationSetupController } from "../orchestration-setup"; -const ThemedUsers = withUnistyles(Users); -type ThemedIcon = typeof ThemedUsers; - -const CAPABILITIES: ReadonlyArray<{ - key: "agents" | "workspaces" | "schedules" | "terminals"; - icon: ThemedIcon; -}> = [ - { key: "agents", icon: ThemedUsers }, - { key: "workspaces", icon: withUnistyles(GitBranch) }, - { key: "schedules", icon: withUnistyles(CalendarClock) }, - { key: "terminals", icon: withUnistyles(SquareTerminal) }, -]; - -const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); - -export function OrchestrationStep({ serverId }: { serverId: string }) { - const { t } = useTranslation(); +/** Three switches, all on to start; Continue applies them. */ +export function OrchestrationStep({ setup }: { setup: OrchestrationSetupController }) { + const { choices, setChoice, offered, isApplying } = setup; + const setTools = useCallback((on: boolean) => setChoice("tools", on), [setChoice]); + const setSkills = useCallback((on: boolean) => setChoice("skills", on), [setChoice]); + const setCli = useCallback((on: boolean) => setChoice("cli", on), [setChoice]); return ( - - - {CAPABILITIES.map(({ key, icon }) => ( - - ))} - - - + + + {offered.skills ? ( + + ) : null} + {offered.cli ? ( + + ) : null} + ); } - -function Capability({ - icon: Icon, - title, - description, -}: { - icon: ThemedIcon; - title: string; - description: string; -}) { - return ( - - - - {title} - {description} - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - container: { - gap: theme.spacing[6], - }, - capabilities: { - gap: theme.spacing[4], - }, - capability: { - flexDirection: "row", - gap: theme.spacing[3], - }, - capabilityText: { - flex: 1, - gap: theme.spacing[1], - }, - capabilityTitle: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, - capabilityDescription: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - lineHeight: theme.fontSize.sm * 1.5, - }, -})); diff --git a/packages/app/src/onboarding/internal/steps/phone-step.tsx b/packages/app/src/onboarding/internal/steps/phone-step.tsx index 6ceafc95a7..582c23a06b 100644 --- a/packages/app/src/onboarding/internal/steps/phone-step.tsx +++ b/packages/app/src/onboarding/internal/steps/phone-step.tsx @@ -1,34 +1,94 @@ -import { useCallback } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { AppleIcon } from "@/components/icons/apple-icon"; +import { GooglePlayIcon } from "@/components/icons/google-play-icon"; +import { SettingsCard, SettingsDetailRow, SettingsSection } from "@/components/settings"; import { Button } from "@/components/ui/button"; -import { PairDeviceSection } from "@/desktop/components/pair-device-section"; +import { StatusBadge } from "@/components/ui/status-badge"; +import { PairDeviceModal } from "@/desktop/components/pair-device-modal"; +import { + CONNECTIVITY_DOCS_URL, + TAILSCALE_DOCS_URL, +} from "@/desktop/components/pair-device-section"; +import { ICON_SIZE } from "@/styles/theme"; import { openExternalUrl } from "@/utils/open-external-url"; const APP_STORE_URL = "https://apps.apple.com/app/paseo-pocket-engineer/id6758887924"; const PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=sh.paseo"; -export function PhoneStep({ serverId, onFinish }: { serverId: string; onFinish: () => void }) { +// Button calls a one-argument function with its icon colour, so the Apple mark follows the theme. +const renderAppleIcon = (color: string) => ; +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 openAppStore = useCallback(() => { - void openExternalUrl(APP_STORE_URL).catch(console.error); - }, []); - const openPlayStore = useCallback(() => { - void openExternalUrl(PLAY_STORE_URL).catch(console.error); - }, []); + const [isPairing, setIsPairing] = useState(false); + const openAppStore = useCallback(() => openUrl(APP_STORE_URL), []); + const openPlayStore = useCallback(() => openUrl(PLAY_STORE_URL), []); + const openTailscaleDocs = useCallback(() => openUrl(TAILSCALE_DOCS_URL), []); + const openPairing = useCallback(() => setIsPairing(true), []); + const closePairing = useCallback(() => setIsPairing(false), []); + const recommendedBadge = useMemo( + () => ( + + ), + [t], + ); + const docsLink = useMemo( + () => ({ + href: CONNECTIVITY_DOCS_URL, + label: t("onboarding.setup.steps.phone.connectDocs"), + accessibilityLabel: t("onboarding.setup.steps.phone.openConnectDocs"), + }), + [t], + ); return ( - - - + + + + + + + ); } diff --git a/packages/app/src/onboarding/internal/steps/projects-step.tsx b/packages/app/src/onboarding/internal/steps/projects-step.tsx index de1c8b219b..9f64f6d839 100644 --- a/packages/app/src/onboarding/internal/steps/projects-step.tsx +++ b/packages/app/src/onboarding/internal/steps/projects-step.tsx @@ -5,10 +5,14 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Check, FolderPlus } from "lucide-react-native"; import { Button } from "@/components/ui/button"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; -import { ProjectSuggestionList, type ProjectSuggestionsController } from "@/project-suggestions"; +import { + FolderProjectIcon, + ProjectSuggestionList, + type ProjectSuggestionsController, +} from "@/project-suggestions"; import { useProjectRootPaths } from "@/stores/session-store-hooks"; import { settingsStyles } from "@/styles/settings"; -import type { Theme } from "@/styles/theme"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; const ThemedCheck = withUnistyles(Check); const successMapping = (theme: Theme) => ({ color: theme.colors.palette.green[400] }); @@ -33,7 +37,11 @@ export function ProjectsStep({ return ( - + + + {projectRootPaths.length > 0 ? ( {projectRootPaths.map((rootPath, index) => ( @@ -42,10 +50,17 @@ export function ProjectsStep({ style={[settingsStyles.row, index > 0 && settingsStyles.rowBorder]} > - - {basename(rootPath)} - - + + + + {basename(rootPath)} + + + {rootPath} @@ -57,13 +72,7 @@ export function ProjectsStep({ ))} ) : null} - - - + ); } @@ -77,6 +86,17 @@ const styles = StyleSheet.create((theme) => ({ flexWrap: "wrap", gap: theme.spacing[2], }, + titleLine: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + title: { + flexShrink: 1, + }, + underTitle: { + marginLeft: ICON_SIZE.md + theme.spacing[2], + }, addedBadge: { flexDirection: "row", alignItems: "center", 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 /> @@ -1308,7 +1308,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({ - - ); -} - -function SuggestionsSkeleton() { - const { t } = useTranslation(); - const pulse = useSkeletonPulse(); return ( - {SKELETON_ROWS.map((row) => ( - 0 && settingsStyles.rowBorder]} - > - - - - - + + + + + {suggestion.displayName} + - ))} + + + {suggestion.rootPath} + + + {t("onboarding.setup.steps.projects.suggestions.lastUsed", { time: lastActivity })} + + {addFailure === undefined ? null : ( + + {addFailure ?? t("onboarding.setup.steps.projects.suggestions.addFailed")} + + )} + + + ); } -const CHECKBOX_SIZE = 16; +/** Placeholder rows in the shape of a suggestion: icon and name, path, and the switch. */ +function SkeletonRows({ afterRows }: { afterRows: boolean }) { + const pulse = useSkeletonPulse(); + const rows = afterRows ? TRAILING_SKELETON_ROWS : INITIAL_SKELETON_ROWS; + return rows.map((row) => 0} />); +} + +function SkeletonRow({ pulse, bordered }: { pulse: Animated.Value; bordered: boolean }) { + return ( + + + + + + + + + + + + + + ); +} const styles = StyleSheet.create((theme) => ({ - container: { - gap: theme.spacing[3], - }, - row: { - justifyContent: "flex-start", - gap: theme.spacing[3], - }, - rowHovered: { - backgroundColor: theme.colors.surface2, - }, - checkbox: { - width: CHECKBOX_SIZE, - height: CHECKBOX_SIZE, - borderRadius: theme.borderRadius.sm, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.borderAccent, - }, - checkboxChecked: { - width: CHECKBOX_SIZE, - height: CHECKBOX_SIZE, - borderRadius: theme.borderRadius.sm, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.accent, - backgroundColor: theme.colors.accent, - alignItems: "center", - justifyContent: "center", - }, - meta: { - alignItems: "flex-end", - gap: theme.spacing[1], - }, - metaText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, - statusRow: { + titleLine: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, + title: { + flexShrink: 1, + }, + // Path and last use line up under the name, past the icon. + underTitle: { + marginLeft: ICON_SIZE.md + theme.spacing[2], + }, + lastUsed: { + color: theme.colors.foregroundExtraMuted, + }, statusText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, - skeletonCheckbox: { - width: CHECKBOX_SIZE, - height: CHECKBOX_SIZE, - borderRadius: theme.borderRadius.sm, + skeletonSwitch: { + width: 34, + height: 20, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface2, }, - skeletonText: { - flex: 1, - gap: theme.spacing[2], + skeletonIcon: { + width: ICON_SIZE.md, + height: ICON_SIZE.md, + borderRadius: projectIconRadius(ICON_SIZE.md), + backgroundColor: theme.colors.surface2, }, skeletonTitle: { - width: 140, + width: 120, height: 12, borderRadius: theme.borderRadius.sm, + backgroundColor: theme.colors.surface2, + }, + skeletonHints: { + marginTop: theme.spacing[2], + gap: theme.spacing[2], }, skeletonPath: { width: 220, + maxWidth: "100%", height: 10, borderRadius: theme.borderRadius.sm, + backgroundColor: theme.colors.surface2, + }, + skeletonLastUsed: { + width: 90, + height: 10, + borderRadius: theme.borderRadius.sm, + backgroundColor: theme.colors.surface2, }, })); diff --git a/packages/app/src/project-suggestions/internal/suggestions.test.ts b/packages/app/src/project-suggestions/internal/suggestions.test.ts index beb6928356..1876e05cbb 100644 --- a/packages/app/src/project-suggestions/internal/suggestions.test.ts +++ b/packages/app/src/project-suggestions/internal/suggestions.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { addSuggestedProjects, - isAwaitingFirstAnswer, + isStillReading, isSuggestionChecked, mergeProviderSuggestions, type ProjectSuggestion, @@ -30,8 +30,7 @@ describe("mergeProviderSuggestions", () => { [ { provider: "claude", - label: "Claude", - status: "ready", + status: "answered", suggestions: [ suggestion("/dev/app", { sessionCount: 2, lastActivityAt: "2026-10-29T00:00:00.000Z" }), suggestion("/dev/docs", { lastActivityAt: "2026-10-20T00:00:00.000Z" }), @@ -39,9 +38,7 @@ describe("mergeProviderSuggestions", () => { }, { provider: "codex", - label: "Codex", - status: "failed", - message: "Timed out", + status: "answered", suggestions: [ suggestion("/dev/app", { sessionCount: 3, @@ -50,7 +47,7 @@ describe("mergeProviderSuggestions", () => { }), ], }, - { provider: "opencode", label: "OpenCode", status: "loading" }, + { provider: "opencode", status: "loading" }, ], new Set(), ); @@ -70,8 +67,7 @@ describe("mergeProviderSuggestions", () => { [ { provider: "claude", - label: "Claude", - status: "ready", + status: "answered", suggestions: [suggestion("/dev/app"), suggestion("/dev/docs")], }, ], @@ -131,19 +127,17 @@ describe("addSuggestedProjects", () => { }); }); -describe("isAwaitingFirstAnswer", () => { - it("stops waiting once any provider answers, so a failure shows while others still read", () => { - const reading: ProviderSuggestions = { provider: "codex", label: "Codex", status: "loading" }; - const failed: ProviderSuggestions = { +describe("isStillReading", () => { + it("keeps reading while any provider has not answered", () => { + const reading: ProviderSuggestions = { provider: "codex", status: "loading" }; + const answered: ProviderSuggestions = { provider: "opencode", - label: "OpenCode", - status: "failed", - message: "Timed out", + status: "answered", suggestions: [], }; - expect(isAwaitingFirstAnswer([reading, { ...reading, provider: "pi" }])).toBe(true); - expect(isAwaitingFirstAnswer([reading, failed])).toBe(false); - expect(isAwaitingFirstAnswer([])).toBe(false); + expect(isStillReading([answered, reading])).toBe(true); + expect(isStillReading([answered, { ...answered, provider: "pi" }])).toBe(false); + expect(isStillReading([])).toBe(false); }); }); diff --git a/packages/app/src/project-suggestions/internal/suggestions.ts b/packages/app/src/project-suggestions/internal/suggestions.ts index fd13a6c7ab..8b0268f1b1 100644 --- a/packages/app/src/project-suggestions/internal/suggestions.ts +++ b/packages/app/src/project-suggestions/internal/suggestions.ts @@ -5,17 +5,13 @@ export type { ProjectSuggestion }; const PRESELECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; const PRESELECT_MIN_SESSIONS = 3; -/** One provider's answer. Each provider is asked separately so a slow one never holds up the rest. */ +/** + * One provider's answer. Each provider is asked separately so a slow one never holds up the rest. + * A provider that cannot read its history answers with whatever it found, often nothing. + */ export type ProviderSuggestions = - | { provider: string; label: string; status: "loading" } - | { provider: string; label: string; status: "ready"; suggestions: ProjectSuggestion[] } - | { - provider: string; - label: string; - status: "failed"; - message: string; - suggestions: ProjectSuggestion[]; - }; + | { provider: string; status: "loading" } + | { provider: string; status: "answered"; suggestions: ProjectSuggestion[] }; /** Combines the providers that answered into one row per folder, most recent first. */ export function mergeProviderSuggestions( @@ -48,12 +44,9 @@ function combineSuggestions(a: ProjectSuggestion, b: ProjectSuggestion): Project }; } -/** - * True until the first provider answers. After that the list shows what it has, including a failed - * provider's Retry, while the slower providers keep reading. - */ -export function isAwaitingFirstAnswer(providers: readonly ProviderSuggestions[]): boolean { - return providers.length > 0 && providers.every((provider) => provider.status === "loading"); +/** True while any provider is still reading its history; the rows so far show meanwhile. */ +export function isStillReading(providers: readonly ProviderSuggestions[]): boolean { + return providers.some((provider) => provider.status === "loading"); } /** Folders the user worked in often and recently start checked. */ diff --git a/packages/app/src/project-suggestions/internal/use-project-suggestions.ts b/packages/app/src/project-suggestions/internal/use-project-suggestions.ts index 19723c177d..1da9a474de 100644 --- a/packages/app/src/project-suggestions/internal/use-project-suggestions.ts +++ b/packages/app/src/project-suggestions/internal/use-project-suggestions.ts @@ -4,11 +4,10 @@ import { useOpenProject } from "@/hooks/use-open-project"; import { useProjectRootPaths } from "@/stores/session-store-hooks"; import { addSuggestedProjects, - isAwaitingFirstAnswer, + isStillReading, isSuggestionChecked, mergeProviderSuggestions, type ProjectSuggestion, - type ProviderSuggestions, } from "./suggestions"; import { useProviderSuggestions } from "./use-provider-suggestions"; @@ -21,18 +20,14 @@ export interface SuggestionRow { export type SuggestionList = | { status: "unsupported" } - /** Nothing to show yet: the provider list or the first provider's answer is pending. */ - | { status: "loading" } | { status: "ready"; rows: SuggestionRow[]; - providerLabels: ReadonlyMap; - reading: ProviderSuggestions[]; - failed: ProviderSuggestions[]; + /** True while any provider, or the provider list itself, is still pending. */ + isReading: boolean; /** True while checked rows are being added; the selection is frozen until it settles. */ isAdding: boolean; toggle: (suggestion: ProjectSuggestion) => void; - retry: (provider: string) => void; }; export interface ProjectSuggestionsController { @@ -88,8 +83,7 @@ export function useProjectSuggestions(serverId: string): ProjectSuggestionsContr }, [mutateAsync, checkedRootPaths]); const list = useMemo((): SuggestionList => { - if (state.status !== "ready") return state; - if (isAwaitingFirstAnswer(state.providers)) return { status: "loading" }; + if (state.status === "unsupported") return state; return { status: "ready", rows: suggestions.map((suggestion) => ({ @@ -97,14 +91,9 @@ export function useProjectSuggestions(serverId: string): ProjectSuggestionsContr checked: checkedRootPaths.includes(suggestion.rootPath), addFailure: addFailures.get(suggestion.rootPath), })), - providerLabels: new Map( - state.providers.map((provider) => [provider.provider, provider.label]), - ), - reading: state.providers.filter((provider) => provider.status === "loading"), - failed: state.providers.filter((provider) => provider.status === "failed"), + isReading: state.status === "loading" || isStillReading(state.providers), isAdding: isPending, toggle, - retry: state.retry, }; }, [state, suggestions, checkedRootPaths, addFailures, isPending, toggle]); diff --git a/packages/app/src/project-suggestions/internal/use-provider-suggestions.ts b/packages/app/src/project-suggestions/internal/use-provider-suggestions.ts index 25e4dc9fdc..0c0cee5af1 100644 --- a/packages/app/src/project-suggestions/internal/use-provider-suggestions.ts +++ b/packages/app/src/project-suggestions/internal/use-provider-suggestions.ts @@ -1,5 +1,4 @@ -import { useCallback, useMemo } from "react"; -import { useQueryClient } from "@tanstack/react-query"; +import { useMemo } from "react"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useFetchQueries } from "@/data/query"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; @@ -16,7 +15,7 @@ export type ProjectSuggestionsState = | { status: "unsupported" } /** The provider list itself has not arrived yet. */ | { status: "loading" } - | { status: "ready"; providers: ProviderSuggestions[]; retry: (provider: string) => void }; + | { status: "ready"; providers: ProviderSuggestions[] }; function suggestionsQueryKey(serverId: string, provider: string) { return ["project-suggestions", serverId, provider] as const; @@ -27,41 +26,30 @@ function suggestionsQueryKey(serverId: string, provider: string) { * calling this early (when setup opens) means the Projects step usually finds them answered. */ export function useProviderSuggestions(serverId: string): ProjectSuggestionsState { - const queryClient = useQueryClient(); const supported = useHostFeature(serverId, "projectSuggestions"); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); const { entries } = useProvidersSnapshot(serverId, { enabled: supported }); const providers = useMemo( - () => - entries - ?.filter((entry) => entry.enabled !== false) - .map((entry) => ({ id: entry.provider, label: entry.label ?? entry.provider })), + () => entries?.filter((entry) => entry.enabled !== false).map((entry) => entry.provider), [entries], ); const results = useFetchQueries( (supported && providers ? providers : []).map((provider) => ({ - queryKey: suggestionsQueryKey(serverId, provider.id), + queryKey: suggestionsQueryKey(serverId, provider), dataShape: "value", enabled: Boolean(client) && isConnected, - // A provider that cannot answer stays failed until the user presses Retry. + // Suggestions are a convenience: a provider that cannot answer simply adds nothing. retry: false, staleTimeMs: SUGGESTIONS_STALE_TIME_MS, queryFn: () => { if (!client) throw new Error("Host disconnected"); - return client.listProjectSuggestions({ providers: [provider.id] }); + return client.listProjectSuggestions({ providers: [provider] }); }, })), ); - const retry = useCallback( - (provider: string) => { - void queryClient.refetchQueries({ queryKey: suggestionsQueryKey(serverId, provider) }); - }, - [queryClient, serverId], - ); - return useMemo((): ProjectSuggestionsState => { if (!supported) return { status: "unsupported" }; if (!providers) return { status: "loading" }; @@ -70,26 +58,19 @@ export function useProviderSuggestions(serverId: string): ProjectSuggestionsStat providers: providers.map((provider, index) => toProviderSuggestions(provider, results[index]), ), - retry, }; - }, [supported, providers, results, retry]); + }, [supported, providers, results]); } function toProviderSuggestions( - provider: { id: string; label: string }, + provider: string, result: { data?: SuggestionsResponse; error: Error | null; isFetching: boolean } | undefined, ): ProviderSuggestions { - const base = { provider: provider.id, label: provider.label }; - const data = result?.data; - const providerError = data?.providerErrors.find((error) => error.provider === provider.id); - const failure = result?.error?.message ?? providerError?.message ?? null; - // A retry shows as reading again; a background refresh of good results keeps the rows. - if (!result || (result.isFetching && (failure !== null || !data))) { - return { ...base, status: "loading" }; + if (result?.data) { + return { provider, status: "answered", suggestions: result.data.suggestions }; } - if (failure !== null) { - return { ...base, status: "failed", message: failure, suggestions: data?.suggestions ?? [] }; + if (result?.error && !result.isFetching) { + return { provider, status: "answered", suggestions: [] }; } - if (!data) return { ...base, status: "loading" }; - return { ...base, status: "ready", suggestions: data.suggestions }; + return { provider, status: "loading" }; } diff --git a/packages/app/src/projects/icons.ts b/packages/app/src/projects/icons.ts index ee52b2b57f..a39e8f4d68 100644 --- a/packages/app/src/projects/icons.ts +++ b/packages/app/src/projects/icons.ts @@ -30,7 +30,7 @@ function legacyIconQueryKey(serverId: string, cwd: string) { return ["projectIcon", serverId, "legacy", cwd] as const; } -function iconDataUri(icon: ProjectIcon | null): string | null { +export function projectIconDataUri(icon: ProjectIcon | null): string | null { if (!icon) return null; return `data:${icon.mimeType};base64,${icon.data}`; } @@ -98,7 +98,7 @@ export function useProjectIcons(input: { () => getHostRuntimeStore().getClient(request.serverId), isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)), ), - select: iconDataUri, + select: projectIconDataUri, }; }), }); diff --git a/packages/app/src/review/comment-sheet.tsx b/packages/app/src/review/comment-sheet.tsx index d54ec0ec96..6c025bbbf2 100644 --- a/packages/app/src/review/comment-sheet.tsx +++ b/packages/app/src/review/comment-sheet.tsx @@ -53,7 +53,6 @@ function OpenReviewCommentSheet({ () => ( @@ -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 bd92f43810..fa87c28cfa 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -405,12 +405,7 @@ function ShowSetupAgainRow() { {t("settings.general.setup.label")} {t("settings.general.setup.description")} - @@ -443,7 +438,7 @@ function DiagnosticsSection({ {t("settings.diagnostics.app.rowTitle")} {t("settings.diagnostics.app.rowHint")} - @@ -456,7 +451,6 @@ function DiagnosticsSection({ @@ -673,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 index 2d6aace91f..df36bac97b 100644 --- a/packages/app/src/screens/settings/paseo-tools-card.tsx +++ b/packages/app/src/screens/settings/paseo-tools-card.tsx @@ -1,24 +1,18 @@ import { useCallback } from "react"; -import { Text, View } from "react-native"; 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"; -import { settingsStyles } from "@/styles/settings"; -/** The host's "give agents Paseo tools" switch; Settings and first-run setup share it. */ +/** The host's "give agents Paseo tools" switch, bound to the host's config. */ export function PaseoToolsCard({ serverId }: { serverId: string }) { - const { t } = useTranslation(); const isConnected = useHostRuntimeIsConnected(serverId); const { config, patchConfig } = useDaemonConfig(serverId); const handleValueChange = useCallback( (next: boolean) => { - void patchConfig({ - mcp: { - injectIntoAgents: next, - }, - }); + void patchConfig({ mcp: { injectIntoAgents: next } }); }, [patchConfig], ); @@ -26,22 +20,37 @@ export function PaseoToolsCard({ serverId }: { serverId: string }) { if (!isConnected) return null; return ( - - - - - {t("settings.host.orchestration.enableTools.title")} - - - {t("settings.host.orchestration.enableTools.hint")} - - - - - + + + + ); +} + +/** 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 69d8367dd9..09b09586d3 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -190,10 +190,7 @@ function ProviderRowsSkeleton() { - - - - + ))} @@ -277,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} - )} @@ -612,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 9fa8a872b0..0341ae48f5 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/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({