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({