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