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