Add sidebar callout surface
This commit is contained in:
@@ -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({
|
||||
</View>
|
||||
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
<UpdateBanner />
|
||||
<UpdateCalloutSource />
|
||||
<DaemonVersionMismatchCalloutSource />
|
||||
<CommandCenter />
|
||||
<ProjectPickerModal />
|
||||
<WorkspaceShortcutTargetsSubscriber
|
||||
@@ -906,18 +907,20 @@ export default function RootLayout() {
|
||||
<QueryProvider>
|
||||
<HostRuntimeBootstrapProvider>
|
||||
<PushNotificationRouter />
|
||||
<ToastProvider>
|
||||
<ProvidersWrapper>
|
||||
<SidebarAnimationProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<OpenProjectListener />
|
||||
<AppWithSidebar>
|
||||
<RootStack />
|
||||
</AppWithSidebar>
|
||||
</HorizontalScrollProvider>
|
||||
</SidebarAnimationProvider>
|
||||
</ProvidersWrapper>
|
||||
</ToastProvider>
|
||||
<SidebarCalloutProvider>
|
||||
<ToastProvider>
|
||||
<ProvidersWrapper>
|
||||
<SidebarAnimationProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<OpenProjectListener />
|
||||
<AppWithSidebar>
|
||||
<RootStack />
|
||||
</AppWithSidebar>
|
||||
</HorizontalScrollProvider>
|
||||
</SidebarAnimationProvider>
|
||||
</ProvidersWrapper>
|
||||
</ToastProvider>
|
||||
</SidebarCalloutProvider>
|
||||
</HostRuntimeBootstrapProvider>
|
||||
</QueryProvider>
|
||||
</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 {
|
||||
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({
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarCalloutSlot />
|
||||
|
||||
<View style={styles.sidebarFooter}>
|
||||
<View style={styles.footerHostSlot}>
|
||||
<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