Add sidebar callout surface
This commit is contained in:
@@ -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%",
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user