diff --git a/packages/app/e2e/browser/viewed-agent-timelines.spec.ts b/packages/app/e2e/browser/viewed-agent-timelines.spec.ts index b941443abe..dcfeb8b8b9 100644 --- a/packages/app/e2e/browser/viewed-agent-timelines.spec.ts +++ b/packages/app/e2e/browser/viewed-agent-timelines.spec.ts @@ -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); diff --git a/packages/app/src/appearance/provider.tsx b/packages/app/src/appearance/provider.tsx index 03fb22b4da..9b09d987c9 100644 --- a/packages/app/src/appearance/provider.tsx +++ b/packages/app/src/appearance/provider.tsx @@ -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 ( {children} ); diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index 9466bc6ba6..6b9009dd6a 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -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(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: , + icon: , }), 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 | null>(null); const dismissDeadlineRef = useRef(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 = ; - } else if (toast.variant === "success") { - defaultIcon = ; - } else if (toast.variant === "warning") { - defaultIcon = ; - } else if (toast.variant === "error") { - defaultIcon = ; - } - const icon = toast.icon ?? defaultIcon; + const topOffset = placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : 0; const content = ( - - - - {icon ? {icon} : null} - {typeof toast.content === "string" ? ( - - {toast.content} - - ) : ( - - {toast.content} - - )} - + + + {toast ? ( + + + + ) : null} ); @@ -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 = ; + } else if (toast.variant === "success") { + defaultIcon = ; + } else if (toast.variant === "warning") { + defaultIcon = ; + } else if (toast.variant === "error") { + defaultIcon = ; + } + const icon = toast.icon ?? defaultIcon; + + return ( + + {icon ? {icon} : null} + {typeof toast.content === "string" ? ( + + {toast.content} + + ) : ( + + {toast.content} + + )} + + ); +} + +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, - }, }));