From 576cc0b66861f7ec29d77010cfbdf9ad44d4c223 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 23 Apr 2026 15:19:40 +0700 Subject: [PATCH] Add sidebar callout surface --- packages/app/src/app/_layout.tsx | 167 +++++------ .../app/src/components/callout-card.test.tsx | 171 +++++++++++ packages/app/src/components/callout-card.tsx | 224 ++++++++++++++ ...n-version-mismatch-callout-source.test.tsx | 209 +++++++++++++ ...daemon-version-mismatch-callout-source.tsx | 79 +++++ packages/app/src/components/left-sidebar.tsx | 71 ++--- .../src/components/sidebar-callout-slot.d.ts | 1 + .../sidebar-callout-slot.native.tsx | 3 + .../components/sidebar-callout-slot.test.tsx | 116 ++++++++ .../components/sidebar-callout-slot.web.tsx | 17 ++ .../contexts/sidebar-callout-context.test.tsx | 215 ++++++++++++++ .../src/contexts/sidebar-callout-context.tsx | 232 +++++++++++++++ .../app/src/desktop/updates/update-banner.tsx | 200 ------------- .../updates/update-callout-source.test.tsx | 274 ++++++++++++++++++ .../desktop/updates/update-callout-source.tsx | 149 ++++++++++ 15 files changed, 1812 insertions(+), 316 deletions(-) create mode 100644 packages/app/src/components/callout-card.test.tsx create mode 100644 packages/app/src/components/callout-card.tsx create mode 100644 packages/app/src/components/daemon-version-mismatch-callout-source.test.tsx create mode 100644 packages/app/src/components/daemon-version-mismatch-callout-source.tsx create mode 100644 packages/app/src/components/sidebar-callout-slot.d.ts create mode 100644 packages/app/src/components/sidebar-callout-slot.native.tsx create mode 100644 packages/app/src/components/sidebar-callout-slot.test.tsx create mode 100644 packages/app/src/components/sidebar-callout-slot.web.tsx create mode 100644 packages/app/src/contexts/sidebar-callout-context.test.tsx create mode 100644 packages/app/src/contexts/sidebar-callout-context.tsx delete mode 100644 packages/app/src/desktop/updates/update-banner.tsx create mode 100644 packages/app/src/desktop/updates/update-callout-source.test.tsx create mode 100644 packages/app/src/desktop/updates/update-callout-source.tsx diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 6bb2a8753..4b95e5477 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -1,5 +1,8 @@ import "@/styles/unistyles"; -import { polyfillCrypto } from "@/polyfills/crypto"; +import { PortalProvider } from "@gorhom/portal"; +import { QueryClientProvider } from "@tanstack/react-query"; +import * as Linking from "expo-linking"; +import * as Notifications from "expo-notifications"; import { Stack, useGlobalSearchParams, @@ -7,95 +10,92 @@ import { usePathname, useRouter, } from "expo-router"; -import { SafeAreaProvider } from "react-native-safe-area-context"; -import { KeyboardProvider } from "react-native-keyboard-controller"; -import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler"; -import { PortalProvider } from "@gorhom/portal"; -import { VoiceProvider } from "@/contexts/voice-context"; -import { useAppSettings } from "@/hooks/use-settings"; -import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; -import { useFaviconStatus } from "@/hooks/use-favicon-status"; -import { View, Text } from "react-native"; -import { UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import { QueryClientProvider } from "@tanstack/react-query"; -import { - getHostRuntimeStore, - useHosts, - useHostMutations, - useHostRuntimeClient, -} from "@/runtime/host-runtime"; -import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; -import { loadSettingsFromStorage } from "@/hooks/use-settings"; -import { useColorScheme } from "@/hooks/use-color-scheme"; -import { useOpenProject } from "@/hooks/use-open-project"; -import { useStableEvent } from "@/hooks/use-stable-event"; -import { SessionProvider } from "@/contexts/session-context"; -import type { HostProfile } from "@/types/host-connection"; import { createContext, + type ReactNode, useCallback, useContext, - useState, useEffect, - type ReactNode, useMemo, useRef, + useState, } from "react"; -import { Platform } from "react-native"; -import * as Linking from "expo-linking"; -import * as Notifications from "expo-notifications"; -import { LeftSidebar } from "@/components/left-sidebar"; +import { Platform, Text, View } from "react-native"; +import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler"; +import { KeyboardProvider } from "react-native-keyboard-controller"; +import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated"; +import { SafeAreaProvider } from "react-native-safe-area-context"; +import { UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { CommandCenter } from "@/components/command-center"; +import { DaemonVersionMismatchCalloutSource } from "@/components/daemon-version-mismatch-callout-source"; import { DownloadToast } from "@/components/download-toast"; -import { UpdateBanner } from "@/desktop/updates/update-banner"; -import { ToastProvider } from "@/contexts/toast-context"; -import { usePanelStore } from "@/stores/panel-store"; -import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated"; -import { - SidebarAnimationProvider, - useSidebarAnimation, -} from "@/contexts/sidebar-animation-context"; +import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; +import { LeftSidebar } from "@/components/left-sidebar"; +import { ProjectPickerModal } from "@/components/project-picker-modal"; +import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog"; +import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber"; +import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; +import { isNative, isWeb } from "@/constants/platform"; import { HorizontalScrollProvider, useHorizontalScrollOptional, } from "@/contexts/horizontal-scroll-context"; -import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; -import { CommandCenter } from "@/components/command-center"; -import { ProjectPickerModal } from "@/components/project-picker-modal"; -import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; -import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog"; -import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber"; -import { resolveActiveHost } from "@/utils/active-host"; -import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; -import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action"; -import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; -import { queryClient } from "@/query/query-client"; -import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle"; +import { SessionProvider } from "@/contexts/session-context"; import { - WEB_NOTIFICATION_CLICK_EVENT, - type WebNotificationClickDetail, - ensureOsNotificationPermission, -} from "@/utils/os-notifications"; + SidebarAnimationProvider, + useSidebarAnimation, +} from "@/contexts/sidebar-animation-context"; +import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context"; +import { ToastProvider } from "@/contexts/toast-context"; +import { VoiceProvider } from "@/contexts/voice-context"; +import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { listenToDesktopEvent } from "@/desktop/electron/events"; -import { getDesktopHost } from "@/desktop/host"; import { updateDesktopWindowControls } from "@/desktop/electron/window"; -import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing"; -import { - buildHostRootRoute, - mapPathnameToServer, - parseServerIdFromPathname, - parseHostAgentRouteFromPathname, - parseWorkspaceOpenIntent, - decodeWorkspaceIdFromPathSegment, -} from "@/utils/host-routes"; -import { useSessionStore } from "@/stores/session-store"; -import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; +import { getDesktopHost } from "@/desktop/host"; +import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source"; +import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action"; +import { useColorScheme } from "@/hooks/use-color-scheme"; +import { useFaviconStatus } from "@/hooks/use-favicon-status"; +import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; +import { useOpenProject } from "@/hooks/use-open-project"; +import { loadSettingsFromStorage, useAppSettings } from "@/hooks/use-settings"; +import { useStableEvent } from "@/hooks/use-stable-event"; import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; -import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; +import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; +import { polyfillCrypto } from "@/polyfills/crypto"; +import { queryClient } from "@/query/query-client"; +import { + getHostRuntimeStore, + useHostMutations, + useHostRuntimeClient, + useHosts, +} from "@/runtime/host-runtime"; import { addBrowserActiveWorkspaceLocationListener, syncNavigationActiveWorkspace, } from "@/stores/navigation-active-workspace-store"; -import { isWeb, isNative } from "@/constants/platform"; +import { usePanelStore } from "@/stores/panel-store"; +import { useSessionStore } from "@/stores/session-store"; +import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; +import type { HostProfile } from "@/types/host-connection"; +import { resolveActiveHost } from "@/utils/active-host"; +import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle"; +import { + buildHostRootRoute, + decodeWorkspaceIdFromPathSegment, + mapPathnameToServer, + parseHostAgentRouteFromPathname, + parseServerIdFromPathname, + parseWorkspaceOpenIntent, +} from "@/utils/host-routes"; +import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing"; +import { + ensureOsNotificationPermission, + WEB_NOTIFICATION_CLICK_EVENT, + type WebNotificationClickDetail, +} from "@/utils/os-notifications"; +import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; +import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; polyfillCrypto(); @@ -499,7 +499,8 @@ function AppContainer({ {isCompactLayout && chromeEnabled && } - + + - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/packages/app/src/components/callout-card.test.tsx b/packages/app/src/components/callout-card.test.tsx new file mode 100644 index 000000000..2f51c610b --- /dev/null +++ b/packages/app/src/components/callout-card.test.tsx @@ -0,0 +1,171 @@ +/** + * @vitest-environment jsdom + */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { theme } = vi.hoisted(() => ({ + theme: { + spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 }, + iconSize: { sm: 14, md: 18 }, + borderWidth: { 1: 1 }, + borderRadius: { sm: 4, md: 6, lg: 8, xl: 12, "2xl": 16, full: 999 }, + fontSize: { xs: 11, sm: 13, base: 15 }, + fontWeight: { normal: "400", medium: "500", semibold: "600" }, + opacity: { 50: 0.5 }, + shadow: { md: {} }, + colors: { + surfaceSidebar: "#111", + surface0: "#000", + surface1: "#111", + surface2: "#222", + surface3: "#333", + foreground: "#fff", + foregroundMuted: "#aaa", + border: "#555", + borderAccent: "#666", + accent: "#0a84ff", + accentForeground: "#fff", + destructive: "#ff4444", + primary: "#0a84ff", + palette: { white: "#fff" }, + }, + }, +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => + typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory, + }, + useUnistyles: () => ({ theme }), +})); + +vi.mock("lucide-react-native", () => { + const createIcon = (name: string) => (props: Record) => + React.createElement("span", { ...props, "data-icon": name }); + return { + X: createIcon("X"), + CheckCircle2: createIcon("CheckCircle2"), + AlertTriangle: createIcon("AlertTriangle"), + }; +}); + +vi.stubGlobal("React", React); +vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + +import { CalloutCard } from "./callout-card"; + +describe("CalloutCard", () => { + let root: Root | null = null; + let container: HTMLElement | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container?.remove(); + container = null; + }); + + it("renders title and description", () => { + act(() => { + root?.render( + , + ); + }); + + expect(container?.textContent).toContain("Update available"); + expect(container?.textContent).toContain("v1.2.3 is ready to install."); + }); + + it("renders an icon next to the title", () => { + act(() => { + root?.render( + } />, + ); + }); + + expect(container?.querySelector('[data-testid="callout-title-icon"]')).not.toBeNull(); + }); + + it("renders one action when one is provided", () => { + const onPress = vi.fn(); + act(() => { + root?.render( + , + ); + }); + + const button = container?.querySelector( + '[data-testid="callout-action-0"]', + ) as HTMLElement | null; + expect(button).not.toBeNull(); + expect(button?.textContent).toContain("Undo"); + }); + + it("renders up to two actions", () => { + act(() => { + root?.render( + , + ); + }); + + expect(container?.querySelector('[data-testid="callout-action-0"]')?.textContent).toContain( + "What's new", + ); + expect(container?.querySelector('[data-testid="callout-action-1"]')?.textContent).toContain( + "Install & restart", + ); + }); + + it("renders no action row when no actions are provided", () => { + act(() => { + root?.render(); + }); + + expect(container?.querySelector('[data-testid="callout-actions"]')).toBeNull(); + }); + + it("renders the dismiss X in the top-left when onDismiss is provided", () => { + const onDismiss = vi.fn(); + act(() => { + root?.render(); + }); + + const dismissButton = container?.querySelector( + '[data-testid="callout-dismiss"]', + ) as HTMLElement | null; + expect(dismissButton).not.toBeNull(); + }); + + it("omits the dismiss button when onDismiss is not provided", () => { + act(() => { + root?.render(); + }); + + expect(container?.querySelector('[data-testid="callout-dismiss"]')).toBeNull(); + }); +}); diff --git a/packages/app/src/components/callout-card.tsx b/packages/app/src/components/callout-card.tsx new file mode 100644 index 000000000..c295ea316 --- /dev/null +++ b/packages/app/src/components/callout-card.tsx @@ -0,0 +1,224 @@ +import { X } from "lucide-react-native"; +import type { ReactNode } from "react"; +import { Pressable, Text, View } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; + +export type CalloutActionVariant = "primary" | "secondary"; + +export interface CalloutAction { + label: string; + onPress: () => void; + variant?: CalloutActionVariant; + disabled?: boolean; + testID?: string; +} + +export type CalloutVariant = "default" | "success" | "error"; + +export interface CalloutCardProps { + title?: string; + description?: ReactNode; + icon?: ReactNode; + variant?: CalloutVariant; + actions?: readonly CalloutAction[]; + onDismiss?: () => void; + testID?: string; +} + +export function CalloutDescriptionText({ children }: { children: ReactNode }) { + return {children}; +} + +export function CalloutCard({ + title, + description, + icon, + variant = "default", + actions, + onDismiss, + testID, +}: CalloutCardProps) { + const { theme } = useUnistyles(); + const visibleActions = (actions ?? []).slice(0, 2); + const hasHeader = title != null || icon != null; + const hasDescription = description != null && description !== ""; + + const containerStyle = [styles.container, variant === "error" ? styles.containerError : null]; + + return ( + + + {hasHeader || onDismiss ? ( + + {hasHeader ? ( + + {icon ? {icon} : null} + {title ? ( + + {title} + + ) : null} + + ) : null} + {onDismiss ? ( + + {({ hovered }) => ( + + )} + + ) : null} + + ) : null} + + {hasDescription ? ( + typeof description === "string" ? ( + {description} + ) : ( + {description} + ) + ) : null} + + {visibleActions.length > 0 ? ( + + {visibleActions.map((action, index) => ( + + ))} + + ) : null} + + + ); +} + +function CalloutActionButton({ action, testID }: { action: CalloutAction; testID?: string }) { + const isPrimary = action.variant === "primary"; + return ( + [ + styles.actionButton, + isPrimary ? styles.actionButtonPrimary : styles.actionButtonSecondary, + pressed ? styles.actionButtonPressed : null, + action.disabled ? styles.actionButtonDisabled : null, + ]} + > + + {action.label} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + width: "100%", + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + borderTopWidth: theme.borderWidth[1], + borderTopColor: theme.colors.border, + gap: theme.spacing[2], + }, + containerError: { + borderTopColor: theme.colors.destructive, + }, + dismissButton: { + width: 14, + height: 14, + alignItems: "center", + justifyContent: "center", + }, + body: { + gap: theme.spacing[2], + }, + topRow: { + flexDirection: "row", + alignItems: "flex-start", + gap: theme.spacing[2], + }, + header: { + flex: 1, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + iconSlot: { + alignItems: "center", + justifyContent: "center", + }, + title: { + flexShrink: 1, + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, + description: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + descriptionSlot: { + flexShrink: 1, + minWidth: 0, + gap: theme.spacing[2], + }, + actionRow: { + flexDirection: "row", + gap: theme.spacing[2], + marginTop: theme.spacing[2], + }, + actionButton: { + flex: 1, + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + borderWidth: theme.borderWidth[1], + alignItems: "center", + justifyContent: "center", + }, + actionButtonPrimary: { + backgroundColor: theme.colors.foreground, + borderColor: theme.colors.foreground, + }, + actionButtonSecondary: { + backgroundColor: "transparent", + borderColor: theme.colors.border, + }, + actionButtonPressed: { + opacity: 0.8, + }, + actionButtonDisabled: { + opacity: 0.5, + }, + actionLabel: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + }, + actionLabelPrimary: { + color: theme.colors.surface0, + }, + actionLabelSecondary: { + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/components/daemon-version-mismatch-callout-source.test.tsx b/packages/app/src/components/daemon-version-mismatch-callout-source.test.tsx new file mode 100644 index 000000000..0ff531705 --- /dev/null +++ b/packages/app/src/components/daemon-version-mismatch-callout-source.test.tsx @@ -0,0 +1,209 @@ +/** + * @vitest-environment jsdom + */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context"; +import { SidebarCalloutSlot } from "./sidebar-callout-slot"; + +const { theme } = vi.hoisted(() => ({ + theme: { + spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 }, + borderWidth: { 1: 1 }, + borderRadius: { md: 6 }, + fontSize: { xs: 11, sm: 13 }, + fontWeight: { medium: "500", semibold: "600" }, + colors: { + surface0: "#000", + foreground: "#fff", + foregroundMuted: "#aaa", + border: "#555", + destructive: "#f44", + }, + }, +})); + +const asyncStorage = vi.hoisted(() => ({ + values: new Map(), + getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + asyncStorage.values.set(key, value); + }), +})); + +const hostsState = vi.hoisted(() => ({ + value: [{ serverId: "server-1", label: "Local daemon" }], +})); + +const sessionState = vi.hoisted(() => ({ + value: { + sessions: { + "server-1": { + serverInfo: { + serverId: "server-1", + hostname: "MacBook", + version: "1.2.2", + }, + }, + }, + }, +})); + +const router = vi.hoisted(() => ({ + navigate: vi.fn(), +})); + +vi.mock("@react-native-async-storage/async-storage", () => ({ + default: asyncStorage, +})); + +vi.mock("expo-router", () => ({ + useRouter: () => router, +})); + +vi.mock("@/runtime/host-runtime", () => ({ + useHosts: () => hostsState.value, +})); + +vi.mock("@/stores/session-store", () => ({ + useSessionStore: (selector: (state: typeof sessionState.value) => unknown) => + selector(sessionState.value), +})); + +vi.mock("@/utils/app-version", () => ({ + resolveAppVersion: () => "1.2.3", +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => + typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory, + }, + useUnistyles: () => ({ theme }), +})); + +vi.mock("lucide-react-native", () => { + const X = (props: Record) => React.createElement("span", props); + return { X }; +}); + +vi.stubGlobal("React", React); +vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + +import { DaemonVersionMismatchCalloutSource } from "./daemon-version-mismatch-callout-source"; + +function Harness() { + return ( + + + + + ); +} + +async function renderHarness(root: Root): Promise { + await act(async () => { + root.render(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("DaemonVersionMismatchCalloutSource", () => { + let root: Root | null = null; + let container: HTMLElement | null = null; + + beforeEach(() => { + hostsState.value = [{ serverId: "server-1", label: "Local daemon" }]; + sessionState.value = { + sessions: { + "server-1": { + serverInfo: { + serverId: "server-1", + hostname: "MacBook", + version: "1.2.2", + }, + }, + }, + }; + router.navigate.mockClear(); + asyncStorage.values.clear(); + asyncStorage.getItem.mockClear(); + asyncStorage.setItem.mockClear(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container?.remove(); + container = null; + }); + + it("registers a daemon mismatch sidebar callout", async () => { + await renderHarness(root!); + + expect( + container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'), + ).not.toBeNull(); + expect(container?.textContent).toContain("Daemon version mismatch"); + expect(container?.textContent).toContain("Local daemon is running v1.2.2"); + expect(container?.textContent).toContain("This app is v1.2.3"); + expect(container?.textContent).toContain( + "For the best experience, keep the daemon and client on the same version.", + ); + }); + + it("does not register a callout when versions match", async () => { + sessionState.value.sessions["server-1"]!.serverInfo!.version = "1.2.3"; + + await renderHarness(root!); + + expect( + container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'), + ).toBeNull(); + }); + + it("opens host settings from the callout action", async () => { + await renderHarness(root!); + + const action = container?.querySelector( + '[data-testid="daemon-version-mismatch-callout-server-1-action-0"]', + ) as HTMLElement | null; + expect(action).not.toBeNull(); + + act(() => { + action?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(router.navigate).toHaveBeenCalledWith("/settings/hosts/server-1"); + }); + + it("persists dismissal for the current app-daemon version pair", async () => { + await renderHarness(root!); + + const dismiss = container?.querySelector( + '[data-testid="daemon-version-mismatch-callout-server-1-dismiss"]', + ) as HTMLElement | null; + expect(dismiss).not.toBeNull(); + + act(() => { + dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(asyncStorage.setItem).toHaveBeenCalledWith( + "@paseo:sidebar-callout-dismissals", + JSON.stringify(["daemon-version-mismatch:server-1:1.2.3:1.2.2"]), + ); + expect( + container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/components/daemon-version-mismatch-callout-source.tsx b/packages/app/src/components/daemon-version-mismatch-callout-source.tsx new file mode 100644 index 000000000..e27960df9 --- /dev/null +++ b/packages/app/src/components/daemon-version-mismatch-callout-source.tsx @@ -0,0 +1,79 @@ +import { useRouter } from "expo-router"; +import { useEffect, useMemo } from "react"; +import { useSidebarCallouts } from "@/contexts/sidebar-callout-context"; +import { formatVersionWithPrefix, isVersionMismatch } from "@/desktop/updates/desktop-updates"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import { useHosts } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; +import { resolveAppVersion } from "@/utils/app-version"; +import { buildSettingsHostRoute } from "@/utils/host-routes"; + +type DaemonVersionMismatch = { + serverId: string; + label: string; + appVersion: string; + daemonVersion: string; +}; + +function useDaemonVersionMismatches(): DaemonVersionMismatch[] { + const hosts = useHosts(); + const sessions = useSessionStore((state) => state.sessions); + const appVersion = resolveAppVersion(); + + return useMemo(() => { + if (!appVersion) { + return []; + } + + return hosts + .map((entry) => ({ + serverId: entry.serverId, + label: entry.label?.trim() || sessions[entry.serverId]?.serverInfo?.hostname || "Daemon", + appVersion, + daemonVersion: sessions[entry.serverId]?.serverInfo?.version ?? null, + })) + .filter((entry): entry is DaemonVersionMismatch => + isVersionMismatch(entry.appVersion, entry.daemonVersion), + ); + }, [appVersion, hosts, sessions]); +} + +function DaemonVersionMismatchRegistration({ mismatch }: { mismatch: DaemonVersionMismatch }) { + const callouts = useSidebarCallouts(); + const router = useRouter(); + const { appVersion, daemonVersion, label, serverId } = mismatch; + const openSettings = useStableEvent(() => { + router.navigate(buildSettingsHostRoute(serverId)); + }); + + useEffect(() => { + return callouts.show({ + id: `daemon-version-mismatch:${serverId}`, + dismissalKey: `daemon-version-mismatch:${serverId}:${appVersion}:${daemonVersion}`, + priority: 300, + title: "Daemon version mismatch", + description: `${label} is running ${formatVersionWithPrefix( + daemonVersion, + )}. This app is ${formatVersionWithPrefix( + appVersion, + )}. For the best experience, keep the daemon and client on the same version.`, + variant: "error", + actions: [{ label: "Open settings", onPress: openSettings, variant: "primary" }], + testID: `daemon-version-mismatch-callout-${serverId}`, + }); + }, [appVersion, callouts, daemonVersion, label, openSettings, serverId]); + + return null; +} + +export function DaemonVersionMismatchCalloutSource() { + const mismatches = useDaemonVersionMismatches(); + + return ( + <> + {mismatches.map((mismatch) => ( + + ))} + + ); +} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index b4571be19..839095e73 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,66 +1,67 @@ +import { router, usePathname } from "expo-router"; +import { MessagesSquare, Plus, Settings } from "lucide-react-native"; import { - memo, - useCallback, - useMemo, - useState, - useEffect, - useRef, type Dispatch, + memo, type ReactElement, type RefObject, type SetStateAction, + useCallback, + useEffect, + useMemo, + useRef, + useState, } from "react"; import { - View, Pressable, + StyleSheet as RNStyleSheet, Text, useWindowDimensions, - StyleSheet as RNStyleSheet, + View, } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { - useAnimatedStyle, - interpolate, Extrapolation, + interpolate, runOnJS, + useAnimatedStyle, useSharedValue, } from "react-native-reanimated"; -import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { MessagesSquare, Plus, Settings } from "lucide-react-native"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { Shortcut } from "@/components/ui/shortcut"; -import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; -import { router, usePathname } from "expo-router"; -import { - usePanelStore, - selectIsAgentListOpen, - MIN_SIDEBAR_WIDTH, - MAX_SIDEBAR_WIDTH, -} from "@/stores/panel-store"; +import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; -import { SidebarWorkspaceList } from "./sidebar-workspace-list"; -import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; +import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; +import { Shortcut } from "@/components/ui/shortcut"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { isWeb } from "@/constants/platform"; +import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; +import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; +import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model"; import { - useSidebarWorkspacesList, type SidebarProjectEntry, + useSidebarWorkspacesList, } from "@/hooks/use-sidebar-workspaces-list"; -import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; -import { useWindowControlsPadding } from "@/utils/desktop-window"; -import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; -import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime"; +import { + MAX_SIDEBAR_WIDTH, + MIN_SIDEBAR_WIDTH, + selectIsAgentListOpen, + usePanelStore, +} from "@/stores/panel-store"; +import { resolveActiveHost } from "@/utils/active-host"; import { formatConnectionStatus } from "@/utils/daemons"; -import { useIsCompactFormFactor } from "@/constants/layout"; +import { useWindowControlsPadding } from "@/utils/desktop-window"; import { buildHostSessionsRoute, buildSettingsRoute, mapPathnameToServer, } from "@/utils/host-routes"; -import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; -import { isWeb } from "@/constants/platform"; -import { resolveActiveHost } from "@/utils/active-host"; +import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; +import { SidebarCalloutSlot } from "./sidebar-callout-slot"; +import { SidebarWorkspaceList } from "./sidebar-workspace-list"; const MIN_CHAT_WIDTH = 400; @@ -714,6 +715,8 @@ function DesktopSidebar({ /> )} + + ({ + theme: { + spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 }, + borderWidth: { 1: 1 }, + borderRadius: { md: 6 }, + fontSize: { xs: 11, sm: 13 }, + fontWeight: { medium: "500", semibold: "600" }, + colors: { + surface0: "#000", + foreground: "#fff", + foregroundMuted: "#aaa", + border: "#555", + destructive: "#f44", + }, + }, +})); + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(async () => null), + setItem: vi.fn(async () => {}), +})); + +vi.mock("@react-native-async-storage/async-storage", () => ({ + default: asyncStorage, +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => + typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory, + }, + useUnistyles: () => ({ theme }), +})); + +vi.mock("lucide-react-native", () => { + const X = (props: Record) => React.createElement("span", props); + return { X }; +}); + +vi.stubGlobal("React", React); +vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + +import { SidebarCalloutProvider, useSidebarCallouts } from "@/contexts/sidebar-callout-context"; +import { SidebarCalloutSlot } from "./sidebar-callout-slot"; + +function RegisterCallout() { + const callouts = useSidebarCallouts(); + useEffect( + () => + callouts.show({ + id: "slot-test", + title: "Update available", + description: "v1 is ready.", + testID: "slot-test-callout", + }), + [callouts], + ); + return null; +} + +describe("SidebarCalloutSlot", () => { + let root: Root | null = null; + let container: HTMLElement | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container?.remove(); + container = null; + }); + + it("renders the active sidebar callout", async () => { + await act(async () => { + root?.render( + + + + , + ); + await Promise.resolve(); + }); + + expect(container?.textContent).toContain("Update available"); + expect(container?.textContent).toContain("v1 is ready."); + }); + + it("renders nothing when there is no active callout", async () => { + await act(async () => { + root?.render( + + + , + ); + await Promise.resolve(); + }); + + expect(container?.querySelector('[data-testid="slot-test-callout"]')).toBeNull(); + }); +}); diff --git a/packages/app/src/components/sidebar-callout-slot.web.tsx b/packages/app/src/components/sidebar-callout-slot.web.tsx new file mode 100644 index 000000000..3f6aeae3a --- /dev/null +++ b/packages/app/src/components/sidebar-callout-slot.web.tsx @@ -0,0 +1,17 @@ +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { SidebarCalloutViewport } from "@/contexts/sidebar-callout-context"; + +export function SidebarCalloutSlot() { + return ( + + + + ); +} + +const styles = StyleSheet.create(() => ({ + slot: { + width: "100%", + }, +})); diff --git a/packages/app/src/contexts/sidebar-callout-context.test.tsx b/packages/app/src/contexts/sidebar-callout-context.test.tsx new file mode 100644 index 000000000..73bbb2c9b --- /dev/null +++ b/packages/app/src/contexts/sidebar-callout-context.test.tsx @@ -0,0 +1,215 @@ +/** + * @vitest-environment jsdom + */ +import React, { act, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { theme } = vi.hoisted(() => ({ + theme: { + spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 }, + borderWidth: { 1: 1 }, + borderRadius: { md: 6 }, + fontSize: { xs: 11, sm: 13 }, + fontWeight: { medium: "500", semibold: "600" }, + colors: { + surface0: "#000", + foreground: "#fff", + foregroundMuted: "#aaa", + border: "#555", + destructive: "#f44", + }, + }, +})); + +const asyncStorage = vi.hoisted(() => ({ + values: new Map(), + getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + asyncStorage.values.set(key, value); + }), +})); + +vi.mock("@react-native-async-storage/async-storage", () => ({ + default: asyncStorage, +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => + typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory, + }, + useUnistyles: () => ({ theme }), +})); + +vi.mock("lucide-react-native", () => { + const X = (props: Record) => React.createElement("span", props); + return { X }; +}); + +vi.stubGlobal("React", React); +vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + +import { + SidebarCalloutProvider, + type SidebarCalloutsApi, + SidebarCalloutViewport, + useSidebarCallouts, +} from "./sidebar-callout-context"; + +function CaptureApi({ onApi }: { onApi: (api: SidebarCalloutsApi) => void }) { + const api = useSidebarCallouts(); + onApi(api); + return null; +} + +describe("SidebarCalloutProvider", () => { + let root: Root | null = null; + let container: HTMLElement | null = null; + let api: SidebarCalloutsApi | null = null; + + beforeEach(async () => { + api = null; + asyncStorage.values.clear(); + asyncStorage.getItem.mockClear(); + asyncStorage.setItem.mockClear(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render( + + (api = nextApi)} /> + + , + ); + await Promise.resolve(); + }); + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container?.remove(); + container = null; + api = null; + }); + + it("shows the highest-priority callout first, then reveals the next when dismissed", () => { + act(() => { + api?.show({ id: "onboarding", priority: 10, title: "Set up scripts" }); + api?.show({ id: "update", priority: 200, title: "Update available" }); + }); + + expect(container?.textContent).toContain("Update available"); + expect(container?.textContent).not.toContain("Set up scripts"); + + act(() => { + api?.dismiss("update"); + }); + + expect(container?.textContent).toContain("Set up scripts"); + expect(container?.textContent).not.toContain("Update available"); + }); + + it("replaces a callout by id without duplicating the queue item", () => { + act(() => { + api?.show({ id: "daemon", title: "Old daemon", description: "v1" }); + api?.show({ id: "daemon", title: "New daemon", description: "v2" }); + }); + + expect(container?.textContent).toContain("New daemon"); + expect(container?.textContent).toContain("v2"); + expect(container?.textContent).not.toContain("Old daemon"); + }); + + it("keeps API consumers from rerendering when callout state changes", () => { + const renders = vi.fn(); + function Producer() { + const callouts = useSidebarCallouts(); + renders(callouts); + useEffect(() => { + callouts.show({ id: "initial", title: "Initial" }); + }, [callouts]); + return null; + } + + act(() => { + root?.render( + + + (api = nextApi)} /> + + , + ); + }); + const firstApi = renders.mock.calls[0]?.[0]; + + act(() => { + api?.show({ id: "later", priority: 10, title: "Later" }); + }); + + expect(renders).toHaveBeenCalledTimes(1); + expect(renders.mock.calls[0]?.[0]).toBe(firstApi); + }); + + it("unregisters only the registration returned by show", () => { + let unregisterOld: (() => void) | null = null; + act(() => { + unregisterOld = api?.show({ id: "update", title: "Old" }) ?? null; + api?.show({ id: "update", title: "New" }); + }); + + act(() => { + unregisterOld?.(); + }); + + expect(container?.textContent).toContain("New"); + }); + + it("persists dismissals by dismissal key", () => { + act(() => { + api?.show({ + id: "update", + dismissalKey: "desktop-update:available:1.2.3", + title: "Update available", + }); + }); + + expect(container?.textContent).toContain("Update available"); + + act(() => { + api?.dismiss("update"); + }); + + expect(container?.textContent).not.toContain("Update available"); + expect(asyncStorage.setItem).toHaveBeenCalledWith( + "@paseo:sidebar-callout-dismissals", + JSON.stringify(["desktop-update:available:1.2.3"]), + ); + + act(() => { + api?.show({ + id: "update", + dismissalKey: "desktop-update:available:1.2.3", + title: "Dismissed update", + }); + }); + + expect(container?.textContent).not.toContain("Dismissed update"); + + act(() => { + api?.show({ + id: "update", + dismissalKey: "desktop-update:available:1.2.4", + title: "New update", + }); + }); + + expect(container?.textContent).toContain("New update"); + }); +}); diff --git a/packages/app/src/contexts/sidebar-callout-context.tsx b/packages/app/src/contexts/sidebar-callout-context.tsx new file mode 100644 index 000000000..6f1a9dc66 --- /dev/null +++ b/packages/app/src/contexts/sidebar-callout-context.tsx @@ -0,0 +1,232 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { + createContext, + type ReactNode, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + type CalloutAction, + CalloutCard, + type CalloutCardProps, + type CalloutVariant, +} from "@/components/callout-card"; +import { useStableEvent } from "@/hooks/use-stable-event"; + +export type SidebarCalloutAction = CalloutAction; + +export interface SidebarCalloutOptions { + id: string; + dismissalKey?: string; + title: string; + description?: ReactNode; + icon?: ReactNode; + variant?: CalloutVariant; + actions?: readonly SidebarCalloutAction[]; + dismissible?: boolean; + priority?: number; + onDismiss?: () => void; + testID?: string; +} + +export interface SidebarCalloutsApi { + show: (callout: SidebarCalloutOptions) => () => void; + dismiss: (id: string) => void; + clear: () => void; +} + +type SidebarCalloutEntry = SidebarCalloutOptions & { + order: number; + priority: number; + token: number; +}; + +const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals"; + +const SidebarCalloutApiContext = createContext(null); +const SidebarCalloutStateContext = createContext(null); + +function normalizeDismissalKey(key: string | null | undefined): string | null { + const trimmed = key?.trim(); + return trimmed ? trimmed : null; +} + +function parseDismissedCalloutKeys(value: string | null): Set { + if (!value) { + return new Set(); + } + + try { + const parsed = JSON.parse(value) as unknown; + if (!Array.isArray(parsed)) { + return new Set(); + } + return new Set(parsed.filter((entry): entry is string => typeof entry === "string")); + } catch { + return new Set(); + } +} + +function persistDismissedCalloutKeys(keys: ReadonlySet): void { + void AsyncStorage.setItem(DISMISSED_CALLOUTS_STORAGE_KEY, JSON.stringify([...keys])).catch( + (error) => { + console.error("[SidebarCallouts] Failed to persist dismissed callouts", error); + }, + ); +} + +function selectActiveCallout(input: { + callouts: readonly SidebarCalloutEntry[]; + dismissedKeys: ReadonlySet; + dismissalStorageLoaded: boolean; +}): SidebarCalloutEntry | null { + const visibleCallouts = input.callouts.filter((entry) => { + const dismissalKey = normalizeDismissalKey(entry.dismissalKey); + if (!dismissalKey) { + return true; + } + return input.dismissalStorageLoaded && !input.dismissedKeys.has(dismissalKey); + }); + + if (visibleCallouts.length === 0) { + return null; + } + return ( + [...visibleCallouts].sort((a, b) => b.priority - a.priority || a.order - b.order)[0] ?? null + ); +} + +export function SidebarCalloutProvider({ children }: { children: ReactNode }) { + const [callouts, setCallouts] = useState([]); + const [dismissedKeys, setDismissedKeys] = useState>(new Set()); + const [dismissalStorageLoaded, setDismissalStorageLoaded] = useState(false); + const calloutsRef = useRef([]); + const dismissedKeysRef = useRef>(new Set()); + const orderRef = useRef(0); + const tokenRef = useRef(0); + + useEffect(() => { + let mounted = true; + void AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY) + .then((value) => { + if (!mounted) { + return; + } + const nextKeys = parseDismissedCalloutKeys(value); + dismissedKeysRef.current = nextKeys; + setDismissedKeys(nextKeys); + }) + .catch((error) => { + console.error("[SidebarCallouts] Failed to load dismissed callouts", error); + }) + .finally(() => { + if (mounted) { + setDismissalStorageLoaded(true); + } + }); + + return () => { + mounted = false; + }; + }, []); + + const show = useStableEvent((callout: SidebarCalloutOptions) => { + tokenRef.current += 1; + const token = tokenRef.current; + const current = calloutsRef.current; + const existing = current.find((entry) => entry.id === callout.id); + const nextEntry: SidebarCalloutEntry = { + ...callout, + priority: callout.priority ?? 0, + order: existing?.order ?? ++orderRef.current, + token, + }; + const next = existing + ? current.map((entry) => (entry.id === callout.id ? nextEntry : entry)) + : [...current, nextEntry]; + + calloutsRef.current = next; + setCallouts(next); + + return () => { + const updated = calloutsRef.current.filter( + (entry) => entry.id !== callout.id || entry.token !== token, + ); + calloutsRef.current = updated; + setCallouts(updated); + }; + }); + + const dismiss = useStableEvent((id: string) => { + const dismissed = calloutsRef.current.find((entry) => entry.id === id) ?? null; + const next = calloutsRef.current.filter((entry) => entry.id !== id); + calloutsRef.current = next; + setCallouts(next); + + const dismissalKey = normalizeDismissalKey(dismissed?.dismissalKey); + if (dismissalKey) { + const nextKeys = new Set(dismissedKeysRef.current); + nextKeys.add(dismissalKey); + dismissedKeysRef.current = nextKeys; + setDismissedKeys(nextKeys); + persistDismissedCalloutKeys(nextKeys); + } + + dismissed?.onDismiss?.(); + }); + + const clear = useStableEvent(() => { + calloutsRef.current = []; + setCallouts([]); + }); + + const api = useMemo(() => ({ show, dismiss, clear }), [clear, dismiss, show]); + const activeCallout = useMemo( + () => selectActiveCallout({ callouts, dismissedKeys, dismissalStorageLoaded }), + [callouts, dismissedKeys, dismissalStorageLoaded], + ); + + return ( + + + {children} + + + ); +} + +export function useSidebarCallouts(): SidebarCalloutsApi { + const api = useContext(SidebarCalloutApiContext); + if (!api) { + throw new Error("useSidebarCallouts must be used within SidebarCalloutProvider"); + } + return api; +} + +export function useActiveSidebarCallout(): SidebarCalloutEntry | null { + return useContext(SidebarCalloutStateContext); +} + +export function SidebarCalloutViewport() { + const activeCallout = useActiveSidebarCallout(); + const api = useSidebarCallouts(); + if (!activeCallout) { + return null; + } + + const cardProps: CalloutCardProps = { + title: activeCallout.title, + description: activeCallout.description, + icon: activeCallout.icon, + variant: activeCallout.variant, + actions: activeCallout.actions, + onDismiss: + activeCallout.dismissible === false ? undefined : () => api.dismiss(activeCallout.id), + testID: activeCallout.testID, + }; + + return ; +} diff --git a/packages/app/src/desktop/updates/update-banner.tsx b/packages/app/src/desktop/updates/update-banner.tsx deleted file mode 100644 index 70aedf327..000000000 --- a/packages/app/src/desktop/updates/update-banner.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { Pressable, Text, View } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { X } from "lucide-react-native"; -import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater"; -import { openExternalUrl } from "@/utils/open-external-url"; - -const CHECK_INTERVAL_MS = 30 * 60 * 1000; -const CHANGELOG_URL = "https://paseo.sh/changelog"; - -export function UpdateBanner() { - const { theme } = useUnistyles(); - const { - isDesktopApp, - status, - availableUpdate, - errorMessage, - checkForUpdates, - installUpdate, - isInstalling, - } = useDesktopAppUpdater(); - const [dismissed, setDismissed] = useState(false); - const intervalRef = useRef | null>(null); - - useEffect(() => { - if (!isDesktopApp) return; - - void checkForUpdates({ silent: true }); - - intervalRef.current = setInterval(() => { - void checkForUpdates({ silent: true }); - }, CHECK_INTERVAL_MS); - - return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - } - }; - }, [isDesktopApp, checkForUpdates]); - - if (!isDesktopApp) return null; - if (dismissed) return null; - if ( - status !== "available" && - status !== "installed" && - status !== "installing" && - status !== "error" - ) - return null; - - const isInstalled = status === "installed"; - const isError = status === "error"; - - function getTitle(): string { - if (isInstalled) return "Update installed"; - if (isInstalling) return "Installing update"; - if (isError) return "Update failed"; - return "Update available"; - } - - function getSubtitle(): string { - if (isInstalled) return "Restart to use the new version."; - if (isInstalling) return "Installing and restarting..."; - if (isError) return errorMessage ?? "Something went wrong."; - return `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`; - } - - return ( - - setDismissed(true)} hitSlop={8} style={styles.closeButton}> - - - - - - {getTitle()} - {getSubtitle()} - - - - void openExternalUrl(CHANGELOG_URL)} - style={({ pressed }) => [styles.outlineButton, pressed && styles.buttonPressed]} - > - What's new - - - {!isInstalled && !isError && ( - void installUpdate()} - disabled={isInstalling} - style={({ pressed }) => [ - styles.primaryButton, - pressed && styles.buttonPressed, - isInstalling && styles.buttonDisabled, - ]} - > - - {isInstalling ? "Installing..." : "Install & restart"} - - - )} - - {isError && ( - void checkForUpdates()} - style={({ pressed }) => [styles.primaryButton, pressed && styles.buttonPressed]} - > - Retry - - )} - - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - container: { - position: "absolute", - bottom: theme.spacing[4], - right: theme.spacing[4], - zIndex: 1000, - }, - banner: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[4], - backgroundColor: theme.colors.surface2, - borderRadius: theme.borderRadius.xl, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - paddingVertical: theme.spacing[3], - paddingLeft: theme.spacing[4], - paddingRight: theme.spacing[3], - ...theme.shadow.md, - maxWidth: 480, - }, - closeButton: { - position: "absolute", - top: -8, - left: -8, - width: 24, - height: 24, - borderRadius: 12, - backgroundColor: theme.colors.surface2, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - alignItems: "center", - justifyContent: "center", - zIndex: 1, - }, - textSection: { - flex: 1, - gap: 2, - }, - title: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, - }, - subtitle: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - }, - actions: { - flexDirection: "row", - gap: theme.spacing[2], - alignItems: "center", - }, - outlineButton: { - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - }, - outlineButtonText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.medium, - }, - primaryButton: { - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, - backgroundColor: theme.colors.foreground, - }, - primaryButtonText: { - color: theme.colors.surface0, - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.medium, - }, - buttonPressed: { - opacity: 0.8, - }, - buttonDisabled: { - opacity: 0.5, - }, -})); diff --git a/packages/app/src/desktop/updates/update-callout-source.test.tsx b/packages/app/src/desktop/updates/update-callout-source.test.tsx new file mode 100644 index 000000000..033cb3ead --- /dev/null +++ b/packages/app/src/desktop/updates/update-callout-source.test.tsx @@ -0,0 +1,274 @@ +/** + * @vitest-environment jsdom + */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SidebarCalloutSlot } from "@/components/sidebar-callout-slot"; +import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context"; + +const { theme } = vi.hoisted(() => ({ + theme: { + spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 }, + iconSize: { sm: 14, md: 18 }, + borderWidth: { 1: 1 }, + borderRadius: { md: 6 }, + fontSize: { xs: 11, sm: 13 }, + fontWeight: { medium: "500", semibold: "600" }, + shadow: { md: {} }, + colors: { + surface0: "#000", + foreground: "#fff", + foregroundMuted: "#aaa", + border: "#555", + destructive: "#ff4444", + }, + }, +})); + +const asyncStorage = vi.hoisted(() => ({ + values: new Map(), + getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + asyncStorage.values.set(key, value); + }), +})); + +vi.mock("@react-native-async-storage/async-storage", () => ({ + default: asyncStorage, +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => + typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory, + }, + useUnistyles: () => ({ theme }), +})); + +vi.mock("lucide-react-native", () => { + const X = (props: Record) => React.createElement("span", props); + const Gift = (props: Record) => + React.createElement("span", { ...props, "data-icon": "Gift" }); + return { Gift, X }; +}); + +vi.mock("@/utils/open-external-url", () => ({ + openExternalUrl: vi.fn(async () => {}), +})); + +const updaterState = vi.hoisted(() => ({ + value: { + isDesktopApp: true, + status: "available" as + | "idle" + | "checking" + | "pending" + | "up-to-date" + | "available" + | "installing" + | "installed" + | "error", + availableUpdate: { + hasUpdate: true, + readyToInstall: true, + currentVersion: "1.2.2", + latestVersion: "1.2.3", + body: null, + date: null, + } as unknown, + errorMessage: null as string | null, + lastCheckedAt: null as number | null, + isChecking: false, + isInstalling: false, + statusText: "", + checkForUpdates: vi.fn(async () => null), + installUpdate: vi.fn(async () => null), + }, +})); + +vi.mock("@/desktop/updates/use-desktop-app-updater", () => ({ + useDesktopAppUpdater: () => updaterState.value, +})); + +vi.stubGlobal("React", React); +vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + +import { UpdateCalloutSource } from "./update-callout-source"; + +function resetUpdaterState(): void { + updaterState.value = { + isDesktopApp: true, + status: "available", + availableUpdate: { + hasUpdate: true, + readyToInstall: true, + currentVersion: "1.2.2", + latestVersion: "1.2.3", + body: null, + date: null, + }, + errorMessage: null, + lastCheckedAt: null, + isChecking: false, + isInstalling: false, + statusText: "", + checkForUpdates: vi.fn(async () => null), + installUpdate: vi.fn(async () => null), + }; +} + +function UpdateCalloutHarness({ withSlot = true }: { withSlot?: boolean }) { + return ( + + + {withSlot ? : null} + + ); +} + +async function renderHarness(root: Root, withSlot = true): Promise { + await act(async () => { + root.render(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("UpdateCalloutSource", () => { + let root: Root | null = null; + let container: HTMLElement | null = null; + + beforeEach(() => { + resetUpdaterState(); + asyncStorage.values.clear(); + asyncStorage.getItem.mockClear(); + asyncStorage.setItem.mockClear(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container?.remove(); + container = null; + }); + + it("registers an update-available sidebar callout with changelog + install actions", async () => { + await renderHarness(root!); + + expect(container?.querySelector('[data-testid="update-callout"]')).not.toBeNull(); + expect(container?.textContent).toContain("Update available"); + expect(container?.textContent).toContain("1.2.3"); + expect(container?.textContent).toContain( + "Upgrading the app will stop running agents and close terminal sessions.", + ); + expect(container?.querySelector('[data-icon="Gift"]')).not.toBeNull(); + expect( + container?.querySelector('[data-testid="update-callout-action-0"]')?.textContent, + ).toContain("What's new"); + expect( + container?.querySelector('[data-testid="update-callout-action-1"]')?.textContent, + ).toContain("Install & restart"); + }); + + it("renders only through the sidebar slot", async () => { + await renderHarness(root!, false); + + expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull(); + }); + + it("shows only the changelog action once the update is installed", async () => { + updaterState.value = { + ...updaterState.value, + status: "installed", + availableUpdate: null, + }; + await renderHarness(root!); + + expect(container?.textContent).toContain("Update installed"); + expect( + container?.querySelector('[data-testid="update-callout-action-0"]')?.textContent, + ).toContain("What's new"); + expect(container?.querySelector('[data-testid="update-callout-action-1"]')).toBeNull(); + }); + + it("disables the install action and shows Installing... while installing", async () => { + updaterState.value = { + ...updaterState.value, + status: "installing", + isInstalling: true, + }; + await renderHarness(root!); + + expect( + container?.querySelector('[data-testid="update-callout-action-1"]')?.textContent, + ).toContain("Installing"); + }); + + it("shows a retry action on error and surfaces the error message", async () => { + updaterState.value = { + ...updaterState.value, + status: "error", + errorMessage: "Download failed", + availableUpdate: null, + }; + await renderHarness(root!); + + expect(container?.textContent).toContain("Update failed"); + expect(container?.textContent).toContain("Download failed"); + expect( + container?.querySelector('[data-testid="update-callout-action-1"]')?.textContent, + ).toContain("Retry"); + }); + + it("renders nothing when not running as a desktop app", async () => { + updaterState.value = { ...updaterState.value, isDesktopApp: false }; + await renderHarness(root!); + + expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull(); + }); + + it("renders nothing when status is idle / checking / up-to-date / pending", async () => { + for (const status of ["idle", "checking", "up-to-date", "pending"] as const) { + resetUpdaterState(); + updaterState.value = { ...updaterState.value, status }; + const innerContainer = document.createElement("div"); + document.body.appendChild(innerContainer); + const innerRoot = createRoot(innerContainer); + await act(async () => { + innerRoot.render(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(innerContainer.querySelector('[data-testid="update-callout"]')).toBeNull(); + act(() => { + innerRoot.unmount(); + }); + innerContainer.remove(); + } + }); + + it("dismisses the registered update callout", async () => { + await renderHarness(root!); + + const dismiss = container?.querySelector( + '[data-testid="update-callout-dismiss"]', + ) as HTMLElement | null; + expect(dismiss).not.toBeNull(); + act(() => { + dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull(); + expect(asyncStorage.setItem).toHaveBeenCalledWith( + "@paseo:sidebar-callout-dismissals", + JSON.stringify(["desktop-update:available:1.2.3"]), + ); + }); +}); diff --git a/packages/app/src/desktop/updates/update-callout-source.tsx b/packages/app/src/desktop/updates/update-callout-source.tsx new file mode 100644 index 000000000..26c804f09 --- /dev/null +++ b/packages/app/src/desktop/updates/update-callout-source.tsx @@ -0,0 +1,149 @@ +import { Gift } from "lucide-react-native"; +import { type ReactNode, useEffect, useRef } from "react"; +import { useUnistyles } from "react-native-unistyles"; +import { CalloutDescriptionText } from "@/components/callout-card"; +import { type SidebarCalloutAction, useSidebarCallouts } from "@/contexts/sidebar-callout-context"; +import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import { openExternalUrl } from "@/utils/open-external-url"; + +const CHECK_INTERVAL_MS = 30 * 60 * 1000; +const CHANGELOG_URL = "https://paseo.sh/changelog"; + +export function UpdateCalloutSource() { + const callouts = useSidebarCallouts(); + const { theme } = useUnistyles(); + const { + isDesktopApp, + status, + availableUpdate, + errorMessage, + checkForUpdates, + installUpdate, + isInstalling, + } = useDesktopAppUpdater(); + const intervalRef = useRef | null>(null); + + const openChangelog = useStableEvent(() => { + void openExternalUrl(CHANGELOG_URL); + }); + const install = useStableEvent(() => { + void installUpdate(); + }); + const retry = useStableEvent(() => { + void checkForUpdates(); + }); + useEffect(() => { + if (!isDesktopApp) return; + + void checkForUpdates({ silent: true }); + + intervalRef.current = setInterval(() => { + void checkForUpdates({ silent: true }); + }, CHECK_INTERVAL_MS); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [isDesktopApp, checkForUpdates]); + + useEffect(() => { + if (!isDesktopApp) { + return; + } + if ( + status !== "available" && + status !== "installed" && + status !== "installing" && + status !== "error" + ) { + return; + } + + const isInstalled = status === "installed"; + const isError = status === "error"; + const isAvailable = !isInstalled && !isInstalling && !isError; + + let title = "Update available"; + if (isInstalled) { + title = "Update installed"; + } else if (isInstalling) { + title = "Installing update"; + } else if (isError) { + title = "Update failed"; + } + + let description: ReactNode = ; + if (isInstalled) { + description = "Restart to use the new version."; + } else if (isInstalling) { + description = "Installing and restarting..."; + } else if (isError) { + description = errorMessage ?? "Something went wrong."; + } else if (availableUpdate?.latestVersion) { + description = ( + + ); + } + + const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: openChangelog }]; + + if (isError) { + actions.push({ label: "Retry", onPress: retry, variant: "primary" }); + } else if (!isInstalled) { + actions.push({ + label: isInstalling ? "Installing..." : "Install & restart", + onPress: install, + variant: "primary", + disabled: isInstalling, + }); + } + + return callouts.show({ + id: "desktop-update", + dismissalKey: `desktop-update:${status}:${availableUpdate?.latestVersion ?? "unknown"}`, + priority: 200, + title, + description, + icon: isAvailable ? ( + + ) : undefined, + variant: isError ? "error" : "default", + actions, + testID: "update-callout", + }); + }, [ + availableUpdate?.latestVersion, + callouts, + errorMessage, + install, + isDesktopApp, + isInstalling, + openChangelog, + retry, + status, + theme.colors.foregroundMuted, + theme.iconSize.sm, + ]); + + return null; +} + +function UpdateAvailableDescription({ versionLabel }: { versionLabel?: string }) { + return ( + <> + + {versionLabel + ? `${versionLabel} is ready to install.` + : "A new version is ready to install."} + + + Upgrading the app will stop running agents and close terminal sessions. + + + ); +}