feat: add delayed toast for history refresh with proper header spacing

This commit is contained in:
Mohamed Boudra
2026-02-21 10:35:30 +07:00
parent 5dae7f854f
commit e6a7c4de42
5 changed files with 101 additions and 15 deletions
@@ -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
+1
View File
@@ -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;
+13 -2
View File
@@ -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 }],
},
@@ -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<ReturnType<typeof setTimeout> | 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: (
<ActivityIndicator
size="small"
color={indicatorColorRef.current}
/>
),
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;
}
};
}, []);
}
@@ -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) {