Refactor agent sync toasts into panel host

This commit is contained in:
Mohamed Boudra
2026-03-19 16:43:14 +07:00
parent 20fa1a3a3b
commit a952112910
4 changed files with 356 additions and 320 deletions

View File

@@ -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,
},
}));

View File

@@ -1,62 +1,9 @@
import { createContext, useContext, type ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
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;
ToastViewport,
useToastHost,
type ToastApi,
} from "@/components/toast-host";
const ToastContext = createContext<ToastApi | null>(null);
@@ -69,259 +16,12 @@ export function useToast(): ToastApi {
}
export function ToastProvider({ children }: { children: ReactNode }) {
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 { api, toast, dismiss } = useToastHost();
return (
<ToastContext.Provider value={api}>
{children}
<ToastViewport toast={toast} onDismiss={() => setToast(null)} />
<ToastViewport toast={toast} onDismiss={dismiss} />
</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,
},
}));

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, type ReactNode } from "react";
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_DURATION_MS = 2200;
@@ -8,22 +8,23 @@ const HISTORY_REFRESH_TOAST_DURATION_MS = 2200;
interface UseDelayedHistoryRefreshToastParams {
isCatchingUp: boolean;
indicatorColor: string;
showToast: (content: ReactNode, options?: ToastShowOptions) => void;
}
export function useDelayedHistoryRefreshToast({
isCatchingUp,
indicatorColor,
showToast,
}: UseDelayedHistoryRefreshToastParams): void {
const toast = useToast();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const wasCatchingUpRef = useRef(false);
const isCatchingUpRef = useRef(false);
const toastRef = useRef(toast);
const showToastRef = useRef(showToast);
const indicatorColorRef = useRef(indicatorColor);
useEffect(() => {
toastRef.current = toast;
}, [toast]);
showToastRef.current = showToast;
}, [showToast]);
useEffect(() => {
indicatorColorRef.current = indicatorColor;
@@ -44,7 +45,7 @@ export function useDelayedHistoryRefreshToast({
if (!isCatchingUpRef.current) {
return;
}
toastRef.current.show("Refreshing", {
showToastRef.current("Refreshing", {
icon: (
<ActivityIndicator
size="small"

View File

@@ -14,6 +14,7 @@ import { GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
import { AgentInputArea } from "@/components/agent-input-area";
import { ToastViewport, useToastHost } from "@/components/toast-host";
import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input";
@@ -41,7 +42,6 @@ import {
useAgentScreenStateMachine,
type AgentScreenMissingState,
} from "@/hooks/use-agent-screen-state-machine";
import { useToast } from "@/contexts/toast-context";
import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
import {
@@ -164,9 +164,9 @@ function AgentScreenContent({
onOpenWorkspaceFile,
}: AgentScreenContentProps) {
const { theme } = useUnistyles();
const toast = useToast();
const insets = useSafeAreaInsets();
const queryClient = useQueryClient();
const panelToast = useToastHost();
const resolvedAgentId = agentId;
const { isArchivingAgent } = useArchiveAgent();
@@ -471,12 +471,12 @@ function AgentScreenContent({
}
if (!reconnectToastArmedRef.current) {
reconnectToastArmedRef.current = true;
toast.show("Reconnecting...", {
panelToast.api.show("Reconnecting...", {
durationMs: 2200,
testID: "agent-reconnecting-toast",
});
}
}, [connectionStatus, toast]);
}, [connectionStatus, panelToast.api]);
useFocusEffect(
useCallback(() => {
@@ -833,14 +833,15 @@ function AgentScreenContent({
useDelayedHistoryRefreshToast({
isCatchingUp: isHistoryRefreshCatchingUp,
indicatorColor: theme.colors.primary,
showToast: panelToast.api.show,
});
useEffect(() => {
if (!shouldEmitSyncErrorToast) {
return;
}
toast.error("Failed to refresh agent. Retrying in background.");
}, [shouldEmitSyncErrorToast, toast]);
panelToast.api.error("Failed to refresh agent. Retrying in background.");
}, [panelToast.api, shouldEmitSyncErrorToast]);
if (viewState.tag === "not_found") {
return (
@@ -935,6 +936,12 @@ function AgentScreenContent({
</View>
) : null}
<ToastViewport
toast={panelToast.toast}
onDismiss={panelToast.dismiss}
placement="panel"
/>
</View>
</FileDropZone>