mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
Keep reconnect toast transitions continuous (#4925)
* fix(app): keep reconnect toast transitions continuous Preserve the toast surface when its content changes, and apply initial appearance before mounting screens so settings hydration cannot restart a visible toast. * test(app): use supported appearance QA coverage
This commit is contained in:
@@ -72,6 +72,7 @@ async function selectAgent(page: Page, title: string) {
|
||||
}
|
||||
|
||||
async function expectForkFailureWithoutOverlappingStatus(page: Page) {
|
||||
const expectContinuousToast = await observeToastReplacement(page);
|
||||
await page.getByRole("button", { name: "Fork chat from here" }).last().click();
|
||||
await page.getByRole("menuitem", { name: "Fork in a new tab", exact: true }).click();
|
||||
await expect(
|
||||
@@ -79,6 +80,41 @@ async function expectForkFailureWithoutOverlappingStatus(page: Page) {
|
||||
).toBeVisible();
|
||||
await expectReconnectingToastGone(page, { timeout: 100 });
|
||||
await expectReconnectingToastVisible(page);
|
||||
await expectContinuousToast();
|
||||
}
|
||||
|
||||
async function observeToastReplacement(page: Page) {
|
||||
const observation = await page
|
||||
.getByRole("alert")
|
||||
.filter({ hasText: "Reconnecting to host" })
|
||||
.evaluateHandle((toast) => {
|
||||
const frames: Array<{ connected: boolean; opacity: number; transform: string }> = [];
|
||||
let frame = 0;
|
||||
const sample = () => {
|
||||
const style = getComputedStyle(toast);
|
||||
frames.push({
|
||||
connected: toast.isConnected,
|
||||
opacity: Number(style.opacity),
|
||||
transform: style.transform,
|
||||
});
|
||||
frame = requestAnimationFrame(sample);
|
||||
};
|
||||
sample();
|
||||
return {
|
||||
stop() {
|
||||
cancelAnimationFrame(frame);
|
||||
return frames;
|
||||
},
|
||||
};
|
||||
});
|
||||
return async () => {
|
||||
const frames = await observation.evaluate((recorder) => recorder.stop());
|
||||
await observation.dispose();
|
||||
expect(frames.length).toBeGreaterThan(1);
|
||||
expect(frames.every((frame) => frame.connected)).toBe(true);
|
||||
expect(Math.min(...frames.map((frame) => frame.opacity))).toBe(1);
|
||||
expect(new Set(frames.map((frame) => frame.transform)).size).toBe(1);
|
||||
};
|
||||
}
|
||||
|
||||
async function countChatCommits(page: Page, agentId: string) {
|
||||
@@ -286,6 +322,7 @@ test.describe("Viewed agent timelines", () => {
|
||||
).toHaveCount(0);
|
||||
// Hold the visible response to observe the connected-but-updating state.
|
||||
gate.holdTimelineResponses(scenario.firstAgentId);
|
||||
const expectContinuousToast = await observeToastReplacement(page);
|
||||
gate.restore();
|
||||
await gate.waitForHeldTimelineResponse();
|
||||
await expectReconnectingToastGone(page);
|
||||
@@ -295,6 +332,7 @@ test.describe("Viewed agent timelines", () => {
|
||||
).toBeVisible();
|
||||
await expect(previousMessage).toBeVisible();
|
||||
await expect(page.getByTestId("agent-updating-toast")).toHaveCSS("opacity", "1");
|
||||
await expectContinuousToast();
|
||||
await page.screenshot({ path: testInfo.outputPath("updating-chat.png") });
|
||||
gate.releaseHeldTimelineResponses();
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo } from "react";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { DEFAULT_THEME_PREFERENCE, useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
import {
|
||||
@@ -44,6 +52,7 @@ function applyTheme({ preference, contributedTheme }: ApplyThemeInput): void {
|
||||
|
||||
export function AppearanceProvider({ children }: { children: ReactNode }) {
|
||||
const { settings, updateSettings, isLoading } = useAppSettings();
|
||||
const [hasAppliedAppearance, setHasAppliedAppearance] = useState(false);
|
||||
const options = usePluginThemeCatalog();
|
||||
const selected = useMemo(() => {
|
||||
if (settings.theme !== PLUGIN_THEME_PREFERENCE) return null;
|
||||
@@ -61,6 +70,7 @@ export function AppearanceProvider({ children }: { children: ReactNode }) {
|
||||
codeFontSize: settings.codeFontSize,
|
||||
syntaxTheme: settings.syntaxTheme,
|
||||
});
|
||||
setHasAppliedAppearance(true);
|
||||
}, [
|
||||
isLoading,
|
||||
selected,
|
||||
@@ -85,6 +95,10 @@ export function AppearanceProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
const value = useMemo(() => ({ options, selected, select }), [options, selected, select]);
|
||||
|
||||
// The first settings load changes appearance keys. Mount screens only after applying it
|
||||
// so startup does not destroy and recreate an already-visible workspace.
|
||||
if (!hasAppliedAppearance) return null;
|
||||
|
||||
return (
|
||||
<ContributedThemesContext.Provider value={value}>{children}</ContributedThemesContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
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 {
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet as NativeStyleSheet,
|
||||
Text,
|
||||
ToastAndroid,
|
||||
View,
|
||||
} from "react-native";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -44,13 +53,22 @@ type ToastViewportPlacement = "app-shell" | "panel";
|
||||
|
||||
const DEFAULT_DURATION_MS = 2200;
|
||||
const TOAST_MAX_WIDTH = 480;
|
||||
const toastEntering = FadeIn.duration(140);
|
||||
const toastExiting = FadeOut.duration(140);
|
||||
const ThemedCheckCircle = withUnistyles(CheckCircle2);
|
||||
const ThemedInfo = withUnistyles(Info);
|
||||
const ThemedWarning = withUnistyles(AlertTriangle);
|
||||
const foregroundIcon = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const infoIcon = (theme: Theme) => ({ color: theme.colors.palette.blue[300] });
|
||||
const successIcon = (theme: Theme) => ({ color: theme.colors.primary });
|
||||
const warningIcon = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
|
||||
const errorIcon = (theme: Theme) => ({ color: theme.colors.destructive });
|
||||
|
||||
export function useToastHost(): {
|
||||
api: ToastApi;
|
||||
toast: ToastState | null;
|
||||
dismiss: () => void;
|
||||
} {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
const idRef = useRef(0);
|
||||
@@ -90,11 +108,11 @@ export function useToastHost(): {
|
||||
copied: (label?: string) =>
|
||||
show(label ? t("common.states.copiedLabel", { label }) : t("common.states.copied"), {
|
||||
variant: "success",
|
||||
icon: <CheckCircle2 size={18} color={theme.colors.foreground} />,
|
||||
icon: <ThemedCheckCircle size={18} uniProps={foregroundIcon} />,
|
||||
}),
|
||||
error: (message: string) => show(message, { variant: "error", durationMs: 3200 }),
|
||||
}),
|
||||
[show, theme.colors.foreground, t],
|
||||
[show, t],
|
||||
);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
@@ -113,11 +131,8 @@ export function ToastViewport({
|
||||
onDismiss: () => void;
|
||||
placement?: ToastViewportPlacement;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
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 dismissDeadlineRef = useRef<number | null>(null);
|
||||
const remainingDurationRef = useRef(0);
|
||||
@@ -129,28 +144,6 @@ export function ToastViewport({
|
||||
}
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
|
||||
const scheduleDismiss = useCallback(
|
||||
(durationMs: number | null) => {
|
||||
clearTimer();
|
||||
@@ -163,10 +156,10 @@ export function ToastViewport({
|
||||
remainingDurationRef.current = nextDurationMs;
|
||||
dismissDeadlineRef.current = Date.now() + nextDurationMs;
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
animateOut();
|
||||
onDismiss();
|
||||
}, nextDurationMs);
|
||||
},
|
||||
[animateOut, clearTimer],
|
||||
[onDismiss, clearTimer],
|
||||
);
|
||||
|
||||
const pauseDismiss = useCallback(() => {
|
||||
@@ -184,107 +177,35 @@ export function ToastViewport({
|
||||
scheduleDismiss(remainingDurationRef.current || toast.durationMs);
|
||||
}, [scheduleDismiss, toast]);
|
||||
|
||||
const toastId = toast?.id;
|
||||
const durationMs = toast?.durationMs ?? null;
|
||||
useEffect(() => {
|
||||
if (!toast) {
|
||||
clearTimer();
|
||||
dismissDeadlineRef.current = null;
|
||||
remainingDurationRef.current = 0;
|
||||
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();
|
||||
|
||||
scheduleDismiss(toast.durationMs);
|
||||
|
||||
return () => {
|
||||
clearTimer();
|
||||
};
|
||||
}, [clearTimer, opacity, scheduleDismiss, toast, translateY]);
|
||||
scheduleDismiss(durationMs);
|
||||
return clearTimer;
|
||||
}, [clearTimer, durationMs, scheduleDismiss, toastId]);
|
||||
|
||||
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 toastVariant = toast?.variant;
|
||||
const toastAnimatedStyle = useMemo(
|
||||
() => [
|
||||
styles.toast,
|
||||
toastVariant === "info" ? styles.toastInfo : null,
|
||||
toastVariant === "success" ? styles.toastSuccess : null,
|
||||
toastVariant === "warning" ? styles.toastWarning : null,
|
||||
toastVariant === "error" ? styles.toastError : null,
|
||||
{
|
||||
marginTop: topOffset,
|
||||
opacity,
|
||||
transform: [{ translateY }],
|
||||
},
|
||||
],
|
||||
[toastVariant, topOffset, opacity, translateY],
|
||||
);
|
||||
const toastMessageStyle = useMemo(
|
||||
() => [styles.message, toastVariant === "error" ? styles.messageError : null],
|
||||
[toastVariant],
|
||||
);
|
||||
|
||||
if (!toast) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let defaultIcon: ReactNode = null;
|
||||
if (toast.variant === "info") {
|
||||
defaultIcon = <Info size={18} color={theme.colors.palette.blue[300]} />;
|
||||
} else if (toast.variant === "success") {
|
||||
defaultIcon = <CheckCircle2 size={18} color={theme.colors.primary} />;
|
||||
} else if (toast.variant === "warning") {
|
||||
defaultIcon = <AlertTriangle size={18} color={theme.colors.palette.amber[500]} />;
|
||||
} else if (toast.variant === "error") {
|
||||
defaultIcon = <AlertTriangle size={18} color={theme.colors.destructive} />;
|
||||
}
|
||||
const icon = toast.icon ?? defaultIcon;
|
||||
const topOffset = placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : 0;
|
||||
|
||||
const content = (
|
||||
<View style={styles.container} pointerEvents="box-none">
|
||||
<View style={styles.widthBoundary} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
testID={toast.testID ?? "app-toast"}
|
||||
onPointerEnter={isWeb ? pauseDismiss : undefined}
|
||||
onPointerLeave={isWeb ? resumeDismiss : undefined}
|
||||
style={toastAnimatedStyle}
|
||||
accessibilityRole="alert"
|
||||
>
|
||||
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
|
||||
{typeof toast.content === "string" ? (
|
||||
<Text testID="app-toast-message" style={toastMessageStyle}>
|
||||
{toast.content}
|
||||
</Text>
|
||||
) : (
|
||||
<View testID="app-toast-message" style={styles.contentSlot}>
|
||||
{toast.content}
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
<View
|
||||
style={styles.container(placement, topOffset)}
|
||||
pointerEvents="box-none"
|
||||
collapsable={false}
|
||||
>
|
||||
<View style={styles.widthBoundary} pointerEvents="box-none" collapsable={false}>
|
||||
{toast ? (
|
||||
<Animated.View
|
||||
entering={toastEntering}
|
||||
exiting={toastExiting}
|
||||
style={animatedStyles.surface}
|
||||
testID={toast.testID ?? "app-toast"}
|
||||
accessibilityRole="alert"
|
||||
>
|
||||
<ToastCard toast={toast} onHoverIn={pauseDismiss} onHoverOut={resumeDismiss} />
|
||||
</Animated.View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -296,15 +217,66 @@ export function ToastViewport({
|
||||
return content;
|
||||
}
|
||||
|
||||
function ToastCard({
|
||||
toast,
|
||||
onHoverIn,
|
||||
onHoverOut,
|
||||
}: {
|
||||
toast: ToastState;
|
||||
onHoverIn: () => void;
|
||||
onHoverOut: () => void;
|
||||
}) {
|
||||
let defaultIcon: ReactNode = null;
|
||||
if (toast.variant === "info") {
|
||||
defaultIcon = <ThemedInfo size={18} uniProps={infoIcon} />;
|
||||
} else if (toast.variant === "success") {
|
||||
defaultIcon = <ThemedCheckCircle size={18} uniProps={successIcon} />;
|
||||
} else if (toast.variant === "warning") {
|
||||
defaultIcon = <ThemedWarning size={18} uniProps={warningIcon} />;
|
||||
} else if (toast.variant === "error") {
|
||||
defaultIcon = <ThemedWarning size={18} uniProps={errorIcon} />;
|
||||
}
|
||||
const icon = toast.icon ?? defaultIcon;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessible={false}
|
||||
onHoverIn={onHoverIn}
|
||||
onHoverOut={onHoverOut}
|
||||
style={[
|
||||
styles.toast,
|
||||
toast.variant === "info" ? styles.toastInfo : null,
|
||||
toast.variant === "warning" ? styles.toastWarning : null,
|
||||
toast.variant === "error" ? styles.toastError : null,
|
||||
]}
|
||||
>
|
||||
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
|
||||
{typeof toast.content === "string" ? (
|
||||
<Text testID="app-toast-message" style={styles.message}>
|
||||
{toast.content}
|
||||
</Text>
|
||||
) : (
|
||||
<View testID="app-toast-message" style={styles.contentSlot}>
|
||||
{toast.content}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const animatedStyles = NativeStyleSheet.create({
|
||||
surface: { alignSelf: "center", maxWidth: "100%" },
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
container: (placement: ToastViewportPlacement, topOffset: number) => ({
|
||||
position: "absolute",
|
||||
left: theme.spacing[4],
|
||||
right: theme.spacing[4],
|
||||
top: 0,
|
||||
top: topOffset + (placement === "app-shell" ? theme.spacing[2] : theme.spacing[3]),
|
||||
zIndex: OVERLAY_Z.toast,
|
||||
alignItems: "center",
|
||||
},
|
||||
}),
|
||||
widthBoundary: {
|
||||
width: "92%",
|
||||
maxWidth: TOAST_MAX_WIDTH,
|
||||
@@ -324,9 +296,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
...theme.shadow.md,
|
||||
},
|
||||
toastSuccess: {
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
toastInfo: {
|
||||
borderColor: theme.colors.palette.blue[300],
|
||||
},
|
||||
@@ -350,7 +319,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
messageError: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user