Refactor agent sync toasts into panel host
This commit is contained in:
@@ -0,0 +1,328 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Platform,
|
||||||
|
Text,
|
||||||
|
ToastAndroid,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
|
||||||
|
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
|
||||||
|
import {
|
||||||
|
HEADER_INNER_HEIGHT,
|
||||||
|
HEADER_INNER_HEIGHT_MOBILE,
|
||||||
|
HEADER_TOP_PADDING_MOBILE,
|
||||||
|
} from "@/constants/layout";
|
||||||
|
|
||||||
|
export type ToastVariant = "default" | "success" | "error";
|
||||||
|
|
||||||
|
export type ToastShowOptions = {
|
||||||
|
icon?: ReactNode;
|
||||||
|
variant?: ToastVariant;
|
||||||
|
durationMs?: number;
|
||||||
|
nativeAndroid?: boolean;
|
||||||
|
testID?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToastState = {
|
||||||
|
id: number;
|
||||||
|
content: ReactNode;
|
||||||
|
nativeMessage: string | null;
|
||||||
|
icon?: ReactNode;
|
||||||
|
variant: ToastVariant;
|
||||||
|
durationMs: number;
|
||||||
|
testID?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToastApi = {
|
||||||
|
show: (content: ReactNode, options?: ToastShowOptions) => void;
|
||||||
|
copied: (label?: string) => void;
|
||||||
|
error: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToastViewportPlacement = "app-shell" | "panel";
|
||||||
|
|
||||||
|
const DEFAULT_DURATION_MS = 2200;
|
||||||
|
|
||||||
|
export function useToastHost(): {
|
||||||
|
api: ToastApi;
|
||||||
|
toast: ToastState | null;
|
||||||
|
dismiss: () => void;
|
||||||
|
} {
|
||||||
|
const [toast, setToast] = useState<ToastState | null>(null);
|
||||||
|
const idRef = useRef(0);
|
||||||
|
|
||||||
|
const show = useCallback(
|
||||||
|
(content: ReactNode, options?: ToastShowOptions) => {
|
||||||
|
const nativeMessage =
|
||||||
|
typeof content === "string"
|
||||||
|
? content.trim()
|
||||||
|
: null;
|
||||||
|
if (!content || nativeMessage === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variant = options?.variant ?? "default";
|
||||||
|
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
|
||||||
|
const nativeAndroid = options?.nativeAndroid ?? false;
|
||||||
|
|
||||||
|
if (Platform.OS === "android" && nativeAndroid && nativeMessage) {
|
||||||
|
const duration =
|
||||||
|
durationMs <= 2500
|
||||||
|
? ToastAndroid.SHORT
|
||||||
|
: ToastAndroid.LONG;
|
||||||
|
ToastAndroid.showWithGravity(
|
||||||
|
nativeMessage,
|
||||||
|
duration,
|
||||||
|
ToastAndroid.TOP
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
idRef.current += 1;
|
||||||
|
setToast({
|
||||||
|
id: idRef.current,
|
||||||
|
content,
|
||||||
|
nativeMessage,
|
||||||
|
icon: options?.icon,
|
||||||
|
variant,
|
||||||
|
durationMs,
|
||||||
|
testID: options?.testID,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const api = useMemo<ToastApi>(
|
||||||
|
() => ({
|
||||||
|
show,
|
||||||
|
copied: (label?: string) =>
|
||||||
|
show(label ? `Copied ${label}` : "Copied", {
|
||||||
|
variant: "success",
|
||||||
|
icon: <CheckCircle2 size={18} />,
|
||||||
|
}),
|
||||||
|
error: (message: string) =>
|
||||||
|
show(message, { variant: "error", durationMs: 3200 }),
|
||||||
|
}),
|
||||||
|
[show]
|
||||||
|
);
|
||||||
|
|
||||||
|
const dismiss = useCallback(() => {
|
||||||
|
setToast(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { api, toast, dismiss };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastViewport({
|
||||||
|
toast,
|
||||||
|
onDismiss,
|
||||||
|
placement = "app-shell",
|
||||||
|
}: {
|
||||||
|
toast: ToastState | null;
|
||||||
|
onDismiss: () => void;
|
||||||
|
placement?: ToastViewportPlacement;
|
||||||
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const translateY = useRef(new Animated.Value(-8)).current;
|
||||||
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const clearTimer = useCallback(() => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const animateOut = useCallback(() => {
|
||||||
|
clearTimer();
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(opacity, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 140,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(translateY, {
|
||||||
|
toValue: -8,
|
||||||
|
duration: 140,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start(({ finished }) => {
|
||||||
|
if (finished) {
|
||||||
|
onDismiss();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [clearTimer, onDismiss, opacity, translateY]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toast) {
|
||||||
|
clearTimer();
|
||||||
|
opacity.setValue(0);
|
||||||
|
translateY.setValue(-8);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimer();
|
||||||
|
opacity.setValue(0);
|
||||||
|
translateY.setValue(-8);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(opacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 140,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(translateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 140,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
animateOut();
|
||||||
|
}, toast.durationMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimer();
|
||||||
|
};
|
||||||
|
}, [animateOut, clearTimer, opacity, toast, translateY]);
|
||||||
|
|
||||||
|
if (!toast) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMobile =
|
||||||
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
|
||||||
|
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||||
|
const topOffset =
|
||||||
|
placement === "app-shell"
|
||||||
|
? insets.top + headerTopPadding + headerHeight + theme.spacing[2]
|
||||||
|
: theme.spacing[3];
|
||||||
|
|
||||||
|
const icon =
|
||||||
|
toast.icon ?? (
|
||||||
|
toast.variant === "success" ? (
|
||||||
|
<CheckCircle2 size={18} color={theme.colors.primary} />
|
||||||
|
) : toast.variant === "error" ? (
|
||||||
|
<AlertTriangle size={18} color={theme.colors.destructive} />
|
||||||
|
) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<View style={styles.container} pointerEvents="box-none">
|
||||||
|
<Animated.View
|
||||||
|
testID={toast.testID ?? "app-toast"}
|
||||||
|
style={[
|
||||||
|
styles.toast,
|
||||||
|
toast.variant === "success" ? styles.toastSuccess : null,
|
||||||
|
toast.variant === "error" ? styles.toastError : null,
|
||||||
|
{
|
||||||
|
marginTop: topOffset,
|
||||||
|
opacity,
|
||||||
|
transform: [{ translateY }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
accessibilityRole="alert"
|
||||||
|
>
|
||||||
|
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
|
||||||
|
{typeof toast.content === "string" ? (
|
||||||
|
<Text
|
||||||
|
testID="app-toast-message"
|
||||||
|
style={[
|
||||||
|
styles.message,
|
||||||
|
toast.variant === "error" ? styles.messageError : null,
|
||||||
|
]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{toast.content}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<View testID="app-toast-message" style={styles.contentSlot}>
|
||||||
|
{toast.content}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
placement === "app-shell" &&
|
||||||
|
Platform.OS === "web" &&
|
||||||
|
typeof document !== "undefined"
|
||||||
|
) {
|
||||||
|
return createPortal(content, getOverlayRoot());
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
position: "absolute",
|
||||||
|
left: theme.spacing[4],
|
||||||
|
right: theme.spacing[4],
|
||||||
|
top: 0,
|
||||||
|
zIndex: OVERLAY_Z.toast,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
alignSelf: "center",
|
||||||
|
maxWidth: "92%",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
toastSuccess: {
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
toastError: {
|
||||||
|
borderColor: theme.colors.destructive,
|
||||||
|
},
|
||||||
|
iconSlot: {
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
contentSlot: {
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
flexShrink: 1,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
},
|
||||||
|
messageError: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,62 +1,9 @@
|
|||||||
|
import { createContext, useContext, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
createContext,
|
ToastViewport,
|
||||||
useCallback,
|
useToastHost,
|
||||||
useContext,
|
type ToastApi,
|
||||||
useEffect,
|
} from "@/components/toast-host";
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
|
||||||
import {
|
|
||||||
Animated,
|
|
||||||
Easing,
|
|
||||||
Platform,
|
|
||||||
Text,
|
|
||||||
ToastAndroid,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
|
||||||
import { CheckCircle2, AlertTriangle } from "lucide-react-native";
|
|
||||||
import {
|
|
||||||
HEADER_INNER_HEIGHT,
|
|
||||||
HEADER_INNER_HEIGHT_MOBILE,
|
|
||||||
HEADER_TOP_PADDING_MOBILE,
|
|
||||||
} from "@/constants/layout";
|
|
||||||
|
|
||||||
type ToastVariant = "default" | "success" | "error";
|
|
||||||
|
|
||||||
export type ToastShowOptions = {
|
|
||||||
icon?: ReactNode;
|
|
||||||
variant?: ToastVariant;
|
|
||||||
durationMs?: number;
|
|
||||||
/**
|
|
||||||
* Set to true to use OS toast on Android.
|
|
||||||
*/
|
|
||||||
nativeAndroid?: boolean;
|
|
||||||
testID?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ToastState = {
|
|
||||||
id: number;
|
|
||||||
content: ReactNode;
|
|
||||||
nativeMessage: string | null;
|
|
||||||
icon?: ReactNode;
|
|
||||||
variant: ToastVariant;
|
|
||||||
durationMs: number;
|
|
||||||
testID?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ToastApi = {
|
|
||||||
show: (content: ReactNode, options?: ToastShowOptions) => void;
|
|
||||||
copied: (label?: string) => void;
|
|
||||||
error: (message: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_DURATION_MS = 2200;
|
|
||||||
|
|
||||||
const ToastContext = createContext<ToastApi | null>(null);
|
const ToastContext = createContext<ToastApi | null>(null);
|
||||||
|
|
||||||
@@ -69,259 +16,12 @@ export function useToast(): ToastApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
const [toast, setToast] = useState<ToastState | null>(null);
|
const { api, toast, dismiss } = useToastHost();
|
||||||
const idRef = useRef(0);
|
|
||||||
|
|
||||||
const show = useCallback(
|
|
||||||
(content: ReactNode, options?: ToastShowOptions) => {
|
|
||||||
const nativeMessage =
|
|
||||||
typeof content === "string"
|
|
||||||
? content.trim()
|
|
||||||
: null;
|
|
||||||
if (!content || nativeMessage === "") return;
|
|
||||||
|
|
||||||
const variant = options?.variant ?? "default";
|
|
||||||
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
|
|
||||||
const nativeAndroid = options?.nativeAndroid ?? false;
|
|
||||||
|
|
||||||
if (Platform.OS === "android" && nativeAndroid && nativeMessage) {
|
|
||||||
const duration =
|
|
||||||
durationMs <= 2500
|
|
||||||
? ToastAndroid.SHORT
|
|
||||||
: ToastAndroid.LONG;
|
|
||||||
ToastAndroid.showWithGravity(
|
|
||||||
nativeMessage,
|
|
||||||
duration,
|
|
||||||
ToastAndroid.TOP
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
idRef.current += 1;
|
|
||||||
setToast({
|
|
||||||
id: idRef.current,
|
|
||||||
content,
|
|
||||||
nativeMessage,
|
|
||||||
icon: options?.icon,
|
|
||||||
variant,
|
|
||||||
durationMs,
|
|
||||||
testID: options?.testID,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const api = useMemo<ToastApi>(
|
|
||||||
() => ({
|
|
||||||
show,
|
|
||||||
copied: (label?: string) =>
|
|
||||||
show(label ? `Copied ${label}` : "Copied", {
|
|
||||||
variant: "success",
|
|
||||||
icon: <CheckCircle2 size={18} />,
|
|
||||||
}),
|
|
||||||
error: (message: string) => show(message, { variant: "error", durationMs: 3200 }),
|
|
||||||
}),
|
|
||||||
[show]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastContext.Provider value={api}>
|
<ToastContext.Provider value={api}>
|
||||||
{children}
|
{children}
|
||||||
<ToastViewport toast={toast} onDismiss={() => setToast(null)} />
|
<ToastViewport toast={toast} onDismiss={dismiss} />
|
||||||
</ToastContext.Provider>
|
</ToastContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToastViewport({
|
|
||||||
toast,
|
|
||||||
onDismiss,
|
|
||||||
}: {
|
|
||||||
toast: ToastState | null;
|
|
||||||
onDismiss: () => void;
|
|
||||||
}) {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const opacity = useRef(new Animated.Value(0)).current;
|
|
||||||
const translateY = useRef(new Animated.Value(-8)).current;
|
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const clearTimer = useCallback(() => {
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
timeoutRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const animateOut = useCallback(() => {
|
|
||||||
clearTimer();
|
|
||||||
Animated.parallel([
|
|
||||||
Animated.timing(opacity, {
|
|
||||||
toValue: 0,
|
|
||||||
duration: 140,
|
|
||||||
easing: Easing.out(Easing.quad),
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
Animated.timing(translateY, {
|
|
||||||
toValue: -8,
|
|
||||||
duration: 140,
|
|
||||||
easing: Easing.out(Easing.quad),
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
]).start(({ finished }) => {
|
|
||||||
if (finished) {
|
|
||||||
onDismiss();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [clearTimer, onDismiss, opacity, translateY]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!toast) {
|
|
||||||
clearTimer();
|
|
||||||
opacity.setValue(0);
|
|
||||||
translateY.setValue(-8);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimer();
|
|
||||||
opacity.setValue(0);
|
|
||||||
translateY.setValue(-8);
|
|
||||||
|
|
||||||
Animated.parallel([
|
|
||||||
Animated.timing(opacity, {
|
|
||||||
toValue: 1,
|
|
||||||
duration: 140,
|
|
||||||
easing: Easing.out(Easing.quad),
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
Animated.timing(translateY, {
|
|
||||||
toValue: 0,
|
|
||||||
duration: 140,
|
|
||||||
easing: Easing.out(Easing.quad),
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
]).start();
|
|
||||||
|
|
||||||
timeoutRef.current = setTimeout(() => {
|
|
||||||
animateOut();
|
|
||||||
}, toast.durationMs);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimer();
|
|
||||||
};
|
|
||||||
}, [animateOut, clearTimer, opacity, toast, translateY]);
|
|
||||||
|
|
||||||
if (!toast) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMobile =
|
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
|
||||||
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
|
|
||||||
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
|
||||||
|
|
||||||
const icon =
|
|
||||||
toast.icon ?? (
|
|
||||||
toast.variant === "success" ? (
|
|
||||||
<CheckCircle2 size={18} color={theme.colors.primary} />
|
|
||||||
) : toast.variant === "error" ? (
|
|
||||||
<AlertTriangle size={18} color={theme.colors.destructive} />
|
|
||||||
) : null
|
|
||||||
);
|
|
||||||
|
|
||||||
const content = (
|
|
||||||
<View style={styles.container} pointerEvents="box-none">
|
|
||||||
<Animated.View
|
|
||||||
testID={toast.testID ?? "app-toast"}
|
|
||||||
style={[
|
|
||||||
styles.toast,
|
|
||||||
toast.variant === "success" ? styles.toastSuccess : null,
|
|
||||||
toast.variant === "error" ? styles.toastError : null,
|
|
||||||
{
|
|
||||||
marginTop:
|
|
||||||
insets.top + headerTopPadding + headerHeight + theme.spacing[2],
|
|
||||||
opacity,
|
|
||||||
transform: [{ translateY }],
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
accessibilityRole="alert"
|
|
||||||
>
|
|
||||||
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
|
|
||||||
{typeof toast.content === "string" ? (
|
|
||||||
<Text
|
|
||||||
testID="app-toast-message"
|
|
||||||
style={[
|
|
||||||
styles.message,
|
|
||||||
toast.variant === "error" ? styles.messageError : null,
|
|
||||||
]}
|
|
||||||
numberOfLines={2}
|
|
||||||
>
|
|
||||||
{toast.content}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<View testID="app-toast-message" style={styles.contentSlot}>
|
|
||||||
{toast.content}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Animated.View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
// On web, portal to overlay root to control stacking order
|
|
||||||
if (Platform.OS === "web" && typeof document !== "undefined") {
|
|
||||||
return createPortal(content, getOverlayRoot());
|
|
||||||
}
|
|
||||||
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
container: {
|
|
||||||
position: "absolute",
|
|
||||||
left: theme.spacing[4],
|
|
||||||
right: theme.spacing[4],
|
|
||||||
top: 0,
|
|
||||||
zIndex: OVERLAY_Z.toast,
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
toast: {
|
|
||||||
alignSelf: "center",
|
|
||||||
maxWidth: "92%",
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[3],
|
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
borderWidth: theme.borderWidth[1],
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
paddingVertical: theme.spacing[3],
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOffset: { width: 0, height: 4 },
|
|
||||||
shadowOpacity: 0.15,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 8,
|
|
||||||
},
|
|
||||||
toastSuccess: {
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
},
|
|
||||||
toastError: {
|
|
||||||
borderColor: theme.colors.destructive,
|
|
||||||
},
|
|
||||||
iconSlot: {
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
contentSlot: {
|
|
||||||
flexShrink: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
flexShrink: 1,
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
fontWeight: theme.fontWeight.normal,
|
|
||||||
},
|
|
||||||
messageError: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
import { ActivityIndicator } from "react-native";
|
import { ActivityIndicator } from "react-native";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import type { ToastShowOptions } from "@/components/toast-host";
|
||||||
|
|
||||||
const HISTORY_REFRESH_TOAST_DELAY_MS = 1000;
|
const HISTORY_REFRESH_TOAST_DELAY_MS = 1000;
|
||||||
const HISTORY_REFRESH_TOAST_DURATION_MS = 2200;
|
const HISTORY_REFRESH_TOAST_DURATION_MS = 2200;
|
||||||
@@ -8,22 +8,23 @@ const HISTORY_REFRESH_TOAST_DURATION_MS = 2200;
|
|||||||
interface UseDelayedHistoryRefreshToastParams {
|
interface UseDelayedHistoryRefreshToastParams {
|
||||||
isCatchingUp: boolean;
|
isCatchingUp: boolean;
|
||||||
indicatorColor: string;
|
indicatorColor: string;
|
||||||
|
showToast: (content: ReactNode, options?: ToastShowOptions) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDelayedHistoryRefreshToast({
|
export function useDelayedHistoryRefreshToast({
|
||||||
isCatchingUp,
|
isCatchingUp,
|
||||||
indicatorColor,
|
indicatorColor,
|
||||||
|
showToast,
|
||||||
}: UseDelayedHistoryRefreshToastParams): void {
|
}: UseDelayedHistoryRefreshToastParams): void {
|
||||||
const toast = useToast();
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const wasCatchingUpRef = useRef(false);
|
const wasCatchingUpRef = useRef(false);
|
||||||
const isCatchingUpRef = useRef(false);
|
const isCatchingUpRef = useRef(false);
|
||||||
const toastRef = useRef(toast);
|
const showToastRef = useRef(showToast);
|
||||||
const indicatorColorRef = useRef(indicatorColor);
|
const indicatorColorRef = useRef(indicatorColor);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
toastRef.current = toast;
|
showToastRef.current = showToast;
|
||||||
}, [toast]);
|
}, [showToast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
indicatorColorRef.current = indicatorColor;
|
indicatorColorRef.current = indicatorColor;
|
||||||
@@ -44,7 +45,7 @@ export function useDelayedHistoryRefreshToast({
|
|||||||
if (!isCatchingUpRef.current) {
|
if (!isCatchingUpRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toastRef.current.show("Refreshing", {
|
showToastRef.current("Refreshing", {
|
||||||
icon: (
|
icon: (
|
||||||
<ActivityIndicator
|
<ActivityIndicator
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { GestureDetector } from "react-native-gesture-handler";
|
|||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
|
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
|
||||||
import { AgentInputArea } from "@/components/agent-input-area";
|
import { AgentInputArea } from "@/components/agent-input-area";
|
||||||
|
import { ToastViewport, useToastHost } from "@/components/toast-host";
|
||||||
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||||
import { FileDropZone } from "@/components/file-drop-zone";
|
import { FileDropZone } from "@/components/file-drop-zone";
|
||||||
import type { ImageAttachment } from "@/components/message-input";
|
import type { ImageAttachment } from "@/components/message-input";
|
||||||
@@ -41,7 +42,6 @@ import {
|
|||||||
useAgentScreenStateMachine,
|
useAgentScreenStateMachine,
|
||||||
type AgentScreenMissingState,
|
type AgentScreenMissingState,
|
||||||
} from "@/hooks/use-agent-screen-state-machine";
|
} from "@/hooks/use-agent-screen-state-machine";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
|
||||||
import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast";
|
import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast";
|
||||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||||
import {
|
import {
|
||||||
@@ -164,9 +164,9 @@ function AgentScreenContent({
|
|||||||
onOpenWorkspaceFile,
|
onOpenWorkspaceFile,
|
||||||
}: AgentScreenContentProps) {
|
}: AgentScreenContentProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const toast = useToast();
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const panelToast = useToastHost();
|
||||||
const resolvedAgentId = agentId;
|
const resolvedAgentId = agentId;
|
||||||
const { isArchivingAgent } = useArchiveAgent();
|
const { isArchivingAgent } = useArchiveAgent();
|
||||||
|
|
||||||
@@ -471,12 +471,12 @@ function AgentScreenContent({
|
|||||||
}
|
}
|
||||||
if (!reconnectToastArmedRef.current) {
|
if (!reconnectToastArmedRef.current) {
|
||||||
reconnectToastArmedRef.current = true;
|
reconnectToastArmedRef.current = true;
|
||||||
toast.show("Reconnecting...", {
|
panelToast.api.show("Reconnecting...", {
|
||||||
durationMs: 2200,
|
durationMs: 2200,
|
||||||
testID: "agent-reconnecting-toast",
|
testID: "agent-reconnecting-toast",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [connectionStatus, toast]);
|
}, [connectionStatus, panelToast.api]);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
@@ -833,14 +833,15 @@ function AgentScreenContent({
|
|||||||
useDelayedHistoryRefreshToast({
|
useDelayedHistoryRefreshToast({
|
||||||
isCatchingUp: isHistoryRefreshCatchingUp,
|
isCatchingUp: isHistoryRefreshCatchingUp,
|
||||||
indicatorColor: theme.colors.primary,
|
indicatorColor: theme.colors.primary,
|
||||||
|
showToast: panelToast.api.show,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldEmitSyncErrorToast) {
|
if (!shouldEmitSyncErrorToast) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.error("Failed to refresh agent. Retrying in background.");
|
panelToast.api.error("Failed to refresh agent. Retrying in background.");
|
||||||
}, [shouldEmitSyncErrorToast, toast]);
|
}, [panelToast.api, shouldEmitSyncErrorToast]);
|
||||||
|
|
||||||
if (viewState.tag === "not_found") {
|
if (viewState.tag === "not_found") {
|
||||||
return (
|
return (
|
||||||
@@ -935,6 +936,12 @@ function AgentScreenContent({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<ToastViewport
|
||||||
|
toast={panelToast.toast}
|
||||||
|
onDismiss={panelToast.dismiss}
|
||||||
|
placement="panel"
|
||||||
|
/>
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
</FileDropZone>
|
</FileDropZone>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user