Add sidebar callout surface

This commit is contained in:
Mohamed Boudra
2026-04-23 15:19:40 +07:00
parent e21ef2fc9b
commit 576cc0b668
15 changed files with 1812 additions and 316 deletions
@@ -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>
</>
);
}