Add sidebar callout surface
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
import "@/styles/unistyles";
|
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 {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
useGlobalSearchParams,
|
useGlobalSearchParams,
|
||||||
@@ -7,95 +10,92 @@ import {
|
|||||||
usePathname,
|
usePathname,
|
||||||
useRouter,
|
useRouter,
|
||||||
} from "expo-router";
|
} 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 {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useState,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
type ReactNode,
|
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform, Text, View } from "react-native";
|
||||||
import * as Linking from "expo-linking";
|
import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import * as Notifications from "expo-notifications";
|
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||||
import { LeftSidebar } from "@/components/left-sidebar";
|
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 { DownloadToast } from "@/components/download-toast";
|
||||||
import { UpdateBanner } from "@/desktop/updates/update-banner";
|
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||||
import { ToastProvider } from "@/contexts/toast-context";
|
import { LeftSidebar } from "@/components/left-sidebar";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||||
import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
|
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||||
import {
|
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||||
SidebarAnimationProvider,
|
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
useSidebarAnimation,
|
import { isNative, isWeb } from "@/constants/platform";
|
||||||
} from "@/contexts/sidebar-animation-context";
|
|
||||||
import {
|
import {
|
||||||
HorizontalScrollProvider,
|
HorizontalScrollProvider,
|
||||||
useHorizontalScrollOptional,
|
useHorizontalScrollOptional,
|
||||||
} from "@/contexts/horizontal-scroll-context";
|
} from "@/contexts/horizontal-scroll-context";
|
||||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
import { SessionProvider } from "@/contexts/session-context";
|
||||||
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 {
|
import {
|
||||||
WEB_NOTIFICATION_CLICK_EVENT,
|
SidebarAnimationProvider,
|
||||||
type WebNotificationClickDetail,
|
useSidebarAnimation,
|
||||||
ensureOsNotificationPermission,
|
} from "@/contexts/sidebar-animation-context";
|
||||||
} from "@/utils/os-notifications";
|
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 { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
|
||||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||||
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
import {
|
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
|
||||||
buildHostRootRoute,
|
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
||||||
mapPathnameToServer,
|
import { useColorScheme } from "@/hooks/use-color-scheme";
|
||||||
parseServerIdFromPathname,
|
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||||
parseHostAgentRouteFromPathname,
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
parseWorkspaceOpenIntent,
|
import { useOpenProject } from "@/hooks/use-open-project";
|
||||||
decodeWorkspaceIdFromPathSegment,
|
import { loadSettingsFromStorage, useAppSettings } from "@/hooks/use-settings";
|
||||||
} from "@/utils/host-routes";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
|
||||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
|
||||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
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 {
|
import {
|
||||||
addBrowserActiveWorkspaceLocationListener,
|
addBrowserActiveWorkspaceLocationListener,
|
||||||
syncNavigationActiveWorkspace,
|
syncNavigationActiveWorkspace,
|
||||||
} from "@/stores/navigation-active-workspace-store";
|
} 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();
|
polyfillCrypto();
|
||||||
|
|
||||||
@@ -499,7 +499,8 @@ function AppContainer({
|
|||||||
</View>
|
</View>
|
||||||
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||||
<DownloadToast />
|
<DownloadToast />
|
||||||
<UpdateBanner />
|
<UpdateCalloutSource />
|
||||||
|
<DaemonVersionMismatchCalloutSource />
|
||||||
<CommandCenter />
|
<CommandCenter />
|
||||||
<ProjectPickerModal />
|
<ProjectPickerModal />
|
||||||
<WorkspaceShortcutTargetsSubscriber
|
<WorkspaceShortcutTargetsSubscriber
|
||||||
@@ -906,18 +907,20 @@ export default function RootLayout() {
|
|||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<HostRuntimeBootstrapProvider>
|
<HostRuntimeBootstrapProvider>
|
||||||
<PushNotificationRouter />
|
<PushNotificationRouter />
|
||||||
<ToastProvider>
|
<SidebarCalloutProvider>
|
||||||
<ProvidersWrapper>
|
<ToastProvider>
|
||||||
<SidebarAnimationProvider>
|
<ProvidersWrapper>
|
||||||
<HorizontalScrollProvider>
|
<SidebarAnimationProvider>
|
||||||
<OpenProjectListener />
|
<HorizontalScrollProvider>
|
||||||
<AppWithSidebar>
|
<OpenProjectListener />
|
||||||
<RootStack />
|
<AppWithSidebar>
|
||||||
</AppWithSidebar>
|
<RootStack />
|
||||||
</HorizontalScrollProvider>
|
</AppWithSidebar>
|
||||||
</SidebarAnimationProvider>
|
</HorizontalScrollProvider>
|
||||||
</ProvidersWrapper>
|
</SidebarAnimationProvider>
|
||||||
</ToastProvider>
|
</ProvidersWrapper>
|
||||||
|
</ToastProvider>
|
||||||
|
</SidebarCalloutProvider>
|
||||||
</HostRuntimeBootstrapProvider>
|
</HostRuntimeBootstrapProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</KeyboardProvider>
|
</KeyboardProvider>
|
||||||
|
|||||||
@@ -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<string, unknown>) =>
|
||||||
|
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(
|
||||||
|
<CalloutCard title="Update available" description="v1.2.3 is ready to install." />,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
<CalloutCard title="Update available" icon={<span data-testid="callout-title-icon" />} />,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
<CalloutCard
|
||||||
|
description="Saved."
|
||||||
|
actions={[{ label: "Undo", onPress }]}
|
||||||
|
testID="callout"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
<CalloutCard
|
||||||
|
title="Update available"
|
||||||
|
description="v1 ready."
|
||||||
|
actions={[
|
||||||
|
{ label: "What's new", onPress: vi.fn() },
|
||||||
|
{ label: "Install & restart", onPress: vi.fn(), variant: "primary" },
|
||||||
|
]}
|
||||||
|
testID="callout"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(<CalloutCard description="Copied" testID="callout" />);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(<CalloutCard description="Saved" onDismiss={onDismiss} testID="callout" />);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(<CalloutCard description="Saved" testID="callout" />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container?.querySelector('[data-testid="callout-dismiss"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 <Text style={styles.description}>{children}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={containerStyle} testID={testID} accessibilityRole="alert">
|
||||||
|
<View style={styles.body}>
|
||||||
|
{hasHeader || onDismiss ? (
|
||||||
|
<View style={styles.topRow}>
|
||||||
|
{hasHeader ? (
|
||||||
|
<View style={styles.header}>
|
||||||
|
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
|
||||||
|
{title ? (
|
||||||
|
<Text style={styles.title} numberOfLines={2}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{onDismiss ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={onDismiss}
|
||||||
|
hitSlop={8}
|
||||||
|
style={styles.dismissButton}
|
||||||
|
testID={testID ? `${testID}-dismiss` : undefined}
|
||||||
|
accessibilityLabel="Dismiss"
|
||||||
|
accessibilityRole="button"
|
||||||
|
>
|
||||||
|
{({ hovered }) => (
|
||||||
|
<X
|
||||||
|
size={14}
|
||||||
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasDescription ? (
|
||||||
|
typeof description === "string" ? (
|
||||||
|
<CalloutDescriptionText>{description}</CalloutDescriptionText>
|
||||||
|
) : (
|
||||||
|
<View style={styles.descriptionSlot}>{description}</View>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{visibleActions.length > 0 ? (
|
||||||
|
<View style={styles.actionRow} testID={testID ? `${testID}-actions` : undefined}>
|
||||||
|
{visibleActions.map((action, index) => (
|
||||||
|
<CalloutActionButton
|
||||||
|
key={`${action.label}-${index}`}
|
||||||
|
action={action}
|
||||||
|
testID={action.testID ?? (testID ? `${testID}-action-${index}` : undefined)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalloutActionButton({ action, testID }: { action: CalloutAction; testID?: string }) {
|
||||||
|
const isPrimary = action.variant === "primary";
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={action.onPress}
|
||||||
|
disabled={action.disabled}
|
||||||
|
testID={testID}
|
||||||
|
accessibilityRole="button"
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.actionButton,
|
||||||
|
isPrimary ? styles.actionButtonPrimary : styles.actionButtonSecondary,
|
||||||
|
pressed ? styles.actionButtonPressed : null,
|
||||||
|
action.disabled ? styles.actionButtonDisabled : null,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.actionLabel,
|
||||||
|
isPrimary ? styles.actionLabelPrimary : styles.actionLabelSecondary,
|
||||||
|
]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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<string, string>(),
|
||||||
|
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<string, unknown>) => 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 (
|
||||||
|
<SidebarCalloutProvider>
|
||||||
|
<DaemonVersionMismatchCalloutSource />
|
||||||
|
<SidebarCalloutSlot />
|
||||||
|
</SidebarCalloutProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderHarness(root: Root): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<Harness />);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) => (
|
||||||
|
<DaemonVersionMismatchRegistration key={mismatch.serverId} mismatch={mismatch} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,66 +1,67 @@
|
|||||||
|
import { router, usePathname } from "expo-router";
|
||||||
|
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
|
||||||
import {
|
import {
|
||||||
memo,
|
|
||||||
useCallback,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
|
memo,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
type SetStateAction,
|
type SetStateAction,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
|
||||||
Pressable,
|
Pressable,
|
||||||
|
StyleSheet as RNStyleSheet,
|
||||||
Text,
|
Text,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
StyleSheet as RNStyleSheet,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useAnimatedStyle,
|
|
||||||
interpolate,
|
|
||||||
Extrapolation,
|
Extrapolation,
|
||||||
|
interpolate,
|
||||||
runOnJS,
|
runOnJS,
|
||||||
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
} from "react-native-reanimated";
|
} 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 { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
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 { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
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 { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
||||||
import {
|
import {
|
||||||
useSidebarWorkspacesList,
|
|
||||||
type SidebarProjectEntry,
|
type SidebarProjectEntry,
|
||||||
|
useSidebarWorkspacesList,
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
} 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 { 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 { formatConnectionStatus } from "@/utils/daemons";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||||
import {
|
import {
|
||||||
buildHostSessionsRoute,
|
buildHostSessionsRoute,
|
||||||
buildSettingsRoute,
|
buildSettingsRoute,
|
||||||
mapPathnameToServer,
|
mapPathnameToServer,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||||
import { resolveActiveHost } from "@/utils/active-host";
|
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||||
|
|
||||||
const MIN_CHAT_WIDTH = 400;
|
const MIN_CHAT_WIDTH = 400;
|
||||||
|
|
||||||
@@ -714,6 +715,8 @@ function DesktopSidebar({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SidebarCalloutSlot />
|
||||||
|
|
||||||
<View style={styles.sidebarFooter}>
|
<View style={styles.sidebarFooter}>
|
||||||
<View style={styles.footerHostSlot}>
|
<View style={styles.footerHostSlot}>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./sidebar-callout-slot.native";
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function SidebarCalloutSlot() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* @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(() => ({
|
||||||
|
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<string, unknown>) => 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(
|
||||||
|
<SidebarCalloutProvider>
|
||||||
|
<RegisterCallout />
|
||||||
|
<SidebarCalloutSlot />
|
||||||
|
</SidebarCalloutProvider>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<SidebarCalloutProvider>
|
||||||
|
<SidebarCalloutSlot />
|
||||||
|
</SidebarCalloutProvider>,
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container?.querySelector('[data-testid="slot-test-callout"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<View style={styles.slot} collapsable={false}>
|
||||||
|
<SidebarCalloutViewport />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create(() => ({
|
||||||
|
slot: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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<string, string>(),
|
||||||
|
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<string, unknown>) => 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(
|
||||||
|
<SidebarCalloutProvider>
|
||||||
|
<CaptureApi onApi={(nextApi) => (api = nextApi)} />
|
||||||
|
<SidebarCalloutViewport />
|
||||||
|
</SidebarCalloutProvider>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<SidebarCalloutProvider>
|
||||||
|
<Producer />
|
||||||
|
<CaptureApi onApi={(nextApi) => (api = nextApi)} />
|
||||||
|
<SidebarCalloutViewport />
|
||||||
|
</SidebarCalloutProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<SidebarCalloutsApi | null>(null);
|
||||||
|
const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null);
|
||||||
|
|
||||||
|
function normalizeDismissalKey(key: string | null | undefined): string | null {
|
||||||
|
const trimmed = key?.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDismissedCalloutKeys(value: string | null): Set<string> {
|
||||||
|
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<string>): 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<string>;
|
||||||
|
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<SidebarCalloutEntry[]>([]);
|
||||||
|
const [dismissedKeys, setDismissedKeys] = useState<Set<string>>(new Set());
|
||||||
|
const [dismissalStorageLoaded, setDismissalStorageLoaded] = useState(false);
|
||||||
|
const calloutsRef = useRef<SidebarCalloutEntry[]>([]);
|
||||||
|
const dismissedKeysRef = useRef<Set<string>>(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<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
|
||||||
|
const activeCallout = useMemo(
|
||||||
|
() => selectActiveCallout({ callouts, dismissedKeys, dismissalStorageLoaded }),
|
||||||
|
[callouts, dismissedKeys, dismissalStorageLoaded],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarCalloutApiContext.Provider value={api}>
|
||||||
|
<SidebarCalloutStateContext.Provider value={activeCallout}>
|
||||||
|
{children}
|
||||||
|
</SidebarCalloutStateContext.Provider>
|
||||||
|
</SidebarCalloutApiContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <CalloutCard {...cardProps} />;
|
||||||
|
}
|
||||||
@@ -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<ReturnType<typeof setInterval> | 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 (
|
|
||||||
<View style={styles.container} pointerEvents="box-none">
|
|
||||||
<Pressable onPress={() => setDismissed(true)} hitSlop={8} style={styles.closeButton}>
|
|
||||||
<X size={12} color={theme.colors.foregroundMuted} />
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<View style={styles.banner}>
|
|
||||||
<View style={styles.textSection}>
|
|
||||||
<Text style={styles.title}>{getTitle()}</Text>
|
|
||||||
<Text style={styles.subtitle}>{getSubtitle()}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.actions}>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => void openExternalUrl(CHANGELOG_URL)}
|
|
||||||
style={({ pressed }) => [styles.outlineButton, pressed && styles.buttonPressed]}
|
|
||||||
>
|
|
||||||
<Text style={styles.outlineButtonText}>What's new</Text>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{!isInstalled && !isError && (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => void installUpdate()}
|
|
||||||
disabled={isInstalling}
|
|
||||||
style={({ pressed }) => [
|
|
||||||
styles.primaryButton,
|
|
||||||
pressed && styles.buttonPressed,
|
|
||||||
isInstalling && styles.buttonDisabled,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={styles.primaryButtonText}>
|
|
||||||
{isInstalling ? "Installing..." : "Install & restart"}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isError && (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => void checkForUpdates()}
|
|
||||||
style={({ pressed }) => [styles.primaryButton, pressed && styles.buttonPressed]}
|
|
||||||
>
|
|
||||||
<Text style={styles.primaryButtonText}>Retry</Text>
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -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<string, string>(),
|
||||||
|
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<string, unknown>) => React.createElement("span", props);
|
||||||
|
const Gift = (props: Record<string, unknown>) =>
|
||||||
|
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 (
|
||||||
|
<SidebarCalloutProvider>
|
||||||
|
<UpdateCalloutSource />
|
||||||
|
{withSlot ? <SidebarCalloutSlot /> : null}
|
||||||
|
</SidebarCalloutProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderHarness(root: Root, withSlot = true): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<UpdateCalloutHarness withSlot={withSlot} />);
|
||||||
|
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(<UpdateCalloutHarness />);
|
||||||
|
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"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<ReturnType<typeof setInterval> | 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 = <UpdateAvailableDescription />;
|
||||||
|
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 = (
|
||||||
|
<UpdateAvailableDescription
|
||||||
|
versionLabel={`v${availableUpdate.latestVersion.replace(/^v/i, "")}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ? (
|
||||||
|
<Gift size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
) : 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 (
|
||||||
|
<>
|
||||||
|
<CalloutDescriptionText>
|
||||||
|
{versionLabel
|
||||||
|
? `${versionLabel} is ready to install.`
|
||||||
|
: "A new version is ready to install."}
|
||||||
|
</CalloutDescriptionText>
|
||||||
|
<CalloutDescriptionText>
|
||||||
|
Upgrading the app will stop running agents and close terminal sessions.
|
||||||
|
</CalloutDescriptionText>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user