From e6a7c4de42d1709ecda42d60b330177a04e17146 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 21 Feb 2026 10:35:30 +0700 Subject: [PATCH] feat: add delayed toast for history refresh with proper header spacing --- .../src/components/headers/screen-header.tsx | 8 +- packages/app/src/constants/layout.ts | 1 + packages/app/src/contexts/toast-context.tsx | 15 +++- .../use-delayed-history-refresh-toast.tsx | 74 +++++++++++++++++++ .../src/screens/agent/agent-ready-screen.tsx | 18 ++--- 5 files changed, 101 insertions(+), 15 deletions(-) create mode 100644 packages/app/src/hooks/use-delayed-history-refresh-toast.tsx diff --git a/packages/app/src/components/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx index 80845e5ad..ae3a7b0b4 100644 --- a/packages/app/src/components/headers/screen-header.tsx +++ b/packages/app/src/components/headers/screen-header.tsx @@ -2,7 +2,11 @@ import type { ReactNode } from "react"; import { View, type StyleProp, type ViewStyle } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, UnistylesRuntime } from "react-native-unistyles"; -import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout"; +import { + HEADER_INNER_HEIGHT, + HEADER_INNER_HEIGHT_MOBILE, + HEADER_TOP_PADDING_MOBILE, +} from "@/constants/layout"; import { useTauriDragHandlers } from "@/utils/tauri-window"; interface ScreenHeaderProps { @@ -25,7 +29,7 @@ export function ScreenHeader({ const insets = useSafeAreaInsets(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; // Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets - const topPadding = isMobile ? 8 : 0; + const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; // On Tauri macOS, enable window dragging and double-click to maximize // Left padding for traffic lights is handled by _layout.tsx when sidebar is collapsed diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index 6a901a92c..aca0be463 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -8,6 +8,7 @@ export const FOOTER_HEIGHT = 75; // This ensures both headers have the same visual height export const HEADER_INNER_HEIGHT = 48; export const HEADER_INNER_HEIGHT_MOBILE = 56; +export const HEADER_TOP_PADDING_MOBILE = 8; // Max width for chat content (stream view, input area, new agent form) export const MAX_CONTENT_WIDTH = 820; diff --git a/packages/app/src/contexts/toast-context.tsx b/packages/app/src/contexts/toast-context.tsx index 5e1ec360c..9a0b49596 100644 --- a/packages/app/src/contexts/toast-context.tsx +++ b/packages/app/src/contexts/toast-context.tsx @@ -19,8 +19,13 @@ import { View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +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"; @@ -209,6 +214,11 @@ function ToastViewport({ 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" ? ( @@ -227,7 +237,8 @@ function ToastViewport({ toast.variant === "success" ? styles.toastSuccess : null, toast.variant === "error" ? styles.toastError : null, { - marginTop: theme.spacing[2] + insets.top, + marginTop: + insets.top + headerTopPadding + headerHeight + theme.spacing[2], opacity, transform: [{ translateY }], }, diff --git a/packages/app/src/hooks/use-delayed-history-refresh-toast.tsx b/packages/app/src/hooks/use-delayed-history-refresh-toast.tsx new file mode 100644 index 000000000..ec04586ed --- /dev/null +++ b/packages/app/src/hooks/use-delayed-history-refresh-toast.tsx @@ -0,0 +1,74 @@ +import { useEffect, useRef } from "react"; +import { ActivityIndicator } from "react-native"; +import { useToast } from "@/contexts/toast-context"; + +const HISTORY_REFRESH_TOAST_DELAY_MS = 1000; +const HISTORY_REFRESH_TOAST_DURATION_MS = 2200; + +interface UseDelayedHistoryRefreshToastParams { + isCatchingUp: boolean; + indicatorColor: string; +} + +export function useDelayedHistoryRefreshToast({ + isCatchingUp, + indicatorColor, +}: UseDelayedHistoryRefreshToastParams): void { + const toast = useToast(); + const timerRef = useRef | null>(null); + const wasCatchingUpRef = useRef(false); + const isCatchingUpRef = useRef(false); + const toastRef = useRef(toast); + const indicatorColorRef = useRef(indicatorColor); + + useEffect(() => { + toastRef.current = toast; + }, [toast]); + + useEffect(() => { + indicatorColorRef.current = indicatorColor; + }, [indicatorColor]); + + useEffect(() => { + isCatchingUpRef.current = isCatchingUp; + + const enteredCatchUp = !wasCatchingUpRef.current && isCatchingUp; + const exitedCatchUp = wasCatchingUpRef.current && !isCatchingUp; + + if (enteredCatchUp) { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + timerRef.current = setTimeout(() => { + timerRef.current = null; + if (!isCatchingUpRef.current) { + return; + } + toastRef.current.show("Refreshing", { + icon: ( + + ), + durationMs: HISTORY_REFRESH_TOAST_DURATION_MS, + testID: "agent-history-refresh-toast", + }); + }, HISTORY_REFRESH_TOAST_DELAY_MS); + } else if (exitedCatchUp && timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + + wasCatchingUpRef.current = isCatchingUp; + }, [isCatchingUp]); + + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, []); +} diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 04fbdf9b2..e2c578ac8 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -71,6 +71,7 @@ import { 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 { derivePendingPermissionKey, @@ -954,24 +955,19 @@ function AgentScreenContent({ [theme.colors.primary, toast] ); - const shouldEmitHistoryRefreshToast = + const isHistoryRefreshCatchingUp = viewState.tag === "ready" && viewState.sync.status === "catching_up" && - viewState.sync.shouldEmitHistoryRefreshToast; + viewState.sync.ui === "toast"; const shouldEmitSyncErrorToast = viewState.tag === "ready" && viewState.sync.status === "sync_error" && viewState.sync.shouldEmitSyncErrorToast; - useEffect(() => { - if (!shouldEmitHistoryRefreshToast) { - return; - } - toast.show("Refreshing agent history...", { - durationMs: 2200, - testID: "agent-history-refresh-toast", - }); - }, [shouldEmitHistoryRefreshToast, toast]); + useDelayedHistoryRefreshToast({ + isCatchingUp: isHistoryRefreshCatchingUp, + indicatorColor: theme.colors.primary, + }); useEffect(() => { if (!shouldEmitSyncErrorToast) {