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