Replace workspace reconnect banner with persistent agent panel toast

The workspace reconnect banner ignored safe-area insets on Android,
overlapping the system status bar. The agent panel already shows a
"Reconnecting..." toast on disconnect, so consolidate on that and drop
the banner entirely. Add persistent toast support (durationMs: null)
and dismiss the toast when the connection returns to online instead of
auto-dismissing after 2.2s.
This commit is contained in:
Mohamed Boudra
2026-05-03 17:17:02 +07:00
parent f33a5191df
commit 1daa131480
5 changed files with 21 additions and 91 deletions

View File

@@ -157,7 +157,7 @@ test.describe("Workspace navigation regression", () => {
}); });
await daemonGate.drop(); await daemonGate.drop();
await expect(page.getByTestId("workspace-reconnect-banner")).toBeVisible({ await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
await expectWorkspaceHeader(page, { await expectWorkspaceHeader(page, {
@@ -172,7 +172,7 @@ test.describe("Workspace navigation regression", () => {
label: "host reconnect", label: "host reconnect",
}); });
daemonGate.restore(); daemonGate.restore();
await expect(page.getByTestId("workspace-reconnect-banner")).toHaveCount(0, { await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, {
timeout: 30_000, timeout: 30_000,
}); });
await monitorReconnect; await monitorReconnect;

View File

@@ -18,7 +18,7 @@ export type ToastVariant = "default" | "success" | "error";
export interface ToastShowOptions { export interface ToastShowOptions {
icon?: ReactNode; icon?: ReactNode;
variant?: ToastVariant; variant?: ToastVariant;
durationMs?: number; durationMs?: number | null;
nativeAndroid?: boolean; nativeAndroid?: boolean;
testID?: string; testID?: string;
} }
@@ -29,7 +29,7 @@ export interface ToastState {
nativeMessage: string | null; nativeMessage: string | null;
icon?: ReactNode; icon?: ReactNode;
variant: ToastVariant; variant: ToastVariant;
durationMs: number; durationMs: number | null;
testID?: string; testID?: string;
} }
@@ -59,11 +59,12 @@ export function useToastHost(): {
} }
const variant = options?.variant ?? "default"; const variant = options?.variant ?? "default";
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS; const durationMs = options?.durationMs === undefined ? DEFAULT_DURATION_MS : options.durationMs;
const nativeAndroid = options?.nativeAndroid ?? false; const nativeAndroid = options?.nativeAndroid ?? false;
if (Platform.OS === "android" && nativeAndroid && nativeMessage) { if (Platform.OS === "android" && nativeAndroid && nativeMessage) {
const duration = durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG; const duration =
durationMs !== null && durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG;
ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP); ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP);
return; return;
} }
@@ -148,8 +149,13 @@ export function ToastViewport({
}, [clearTimer, onDismiss, opacity, translateY]); }, [clearTimer, onDismiss, opacity, translateY]);
const scheduleDismiss = useCallback( const scheduleDismiss = useCallback(
(durationMs: number) => { (durationMs: number | null) => {
clearTimer(); clearTimer();
if (durationMs === null) {
remainingDurationRef.current = 0;
dismissDeadlineRef.current = null;
return;
}
const nextDurationMs = Math.max(0, durationMs); const nextDurationMs = Math.max(0, durationMs);
remainingDurationRef.current = nextDurationMs; remainingDurationRef.current = nextDurationMs;
dismissDeadlineRef.current = Date.now() + nextDurationMs; dismissDeadlineRef.current = Date.now() + nextDurationMs;
@@ -169,7 +175,7 @@ export function ToastViewport({
}, [clearTimer]); }, [clearTimer]);
const resumeDismiss = useCallback(() => { const resumeDismiss = useCallback(() => {
if (!toast) { if (!toast || toast.durationMs === null) {
return; return;
} }
scheduleDismiss(remainingDurationRef.current || toast.durationMs); scheduleDismiss(remainingDurationRef.current || toast.durationMs);
@@ -204,7 +210,6 @@ export function ToastViewport({
}), }),
]).start(); ]).start();
remainingDurationRef.current = toast.durationMs;
scheduleDismiss(toast.durationMs); scheduleDismiss(toast.durationMs);
return () => { return () => {

View File

@@ -708,7 +708,10 @@ function ChatAgentContent({
useEffect(() => { useEffect(() => {
if (connectionStatus === "online") { if (connectionStatus === "online") {
reconnectToastArmedRef.current = false; if (reconnectToastArmedRef.current) {
reconnectToastArmedRef.current = false;
panelToast.dismiss();
}
return; return;
} }
if (connectionStatus === "idle") { if (connectionStatus === "idle") {
@@ -717,11 +720,11 @@ function ChatAgentContent({
if (!reconnectToastArmedRef.current) { if (!reconnectToastArmedRef.current) {
reconnectToastArmedRef.current = true; reconnectToastArmedRef.current = true;
panelToast.api.show("Reconnecting...", { panelToast.api.show("Reconnecting...", {
durationMs: 2200, durationMs: null,
testID: "agent-reconnecting-toast", testID: "agent-reconnecting-toast",
}); });
} }
}, [connectionStatus, panelToast.api]); }, [connectionStatus, panelToast]);
useEffect(() => { useEffect(() => {
if (!isPaneFocused || !agentId || !isConnected || !hasSession) { if (!isPaneFocused || !agentId || !isConnected || !hasSession) {

View File

@@ -41,16 +41,6 @@ export function renderWorkspaceRouteGate(input: {
} }
} }
export function renderWorkspaceReconnectIndicator(input: {
state: WorkspaceRouteState;
onRetryHost: () => void;
}): React.ReactNode {
if (input.state.kind !== "reconnecting") {
return null;
}
return <WorkspaceReconnectBanner state={input.state} onRetry={input.onRetryHost} />;
}
function getWorkspaceHostStateTitle( function getWorkspaceHostStateTitle(
state: Extract<WorkspaceRouteState, { kind: "unreachable" }>, state: Extract<WorkspaceRouteState, { kind: "unreachable" }>,
): string { ): string {
@@ -63,15 +53,6 @@ function getWorkspaceHostStateTitle(
return `Cannot reach ${state.hostName}`; return `Cannot reach ${state.hostName}`;
} }
function getReconnectBannerCopy(
state: Extract<WorkspaceRouteState, { kind: "reconnecting" }>,
): string {
if (state.connectionStatus === "connecting" || state.connectionStatus === "idle") {
return `Reconnecting to ${state.hostName}...`;
}
return `${state.hostName} is offline`;
}
function WorkspaceConnecting({ hostName }: { hostName: string }) { function WorkspaceConnecting({ hostName }: { hostName: string }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -135,33 +116,6 @@ function WorkspaceUnreachable({
); );
} }
function WorkspaceReconnectBanner({
state,
onRetry,
}: {
state: Extract<WorkspaceRouteState, { kind: "reconnecting" }>;
onRetry: () => void;
}) {
const { theme } = useUnistyles();
const canRetry = state.connectionStatus === "offline" || state.connectionStatus === "error";
const showSpinner = state.connectionStatus === "connecting" || state.connectionStatus === "idle";
const isErrorState = state.connectionStatus === "offline" || state.connectionStatus === "error";
return (
<View style={styles.reconnectBanner} testID="workspace-reconnect-banner">
{showSpinner ? <LoadingSpinner size="small" color={theme.colors.foregroundMuted} /> : null}
<Text style={isErrorState ? styles.reconnectTextDestructive : styles.reconnectText}>
{getReconnectBannerCopy(state)}
</Text>
{canRetry ? (
<Button size="sm" variant="outline" leftIcon={RotateCw} onPress={onRetry}>
Retry
</Button>
) : null}
</View>
);
}
function WorkspaceMissing({ hostName, onDismiss }: { hostName: string; onDismiss: () => void }) { function WorkspaceMissing({ hostName, onDismiss }: { hostName: string; onDismiss: () => void }) {
return ( return (
<View style={styles.emptyState}> <View style={styles.emptyState}>
@@ -220,28 +174,4 @@ const styles = StyleSheet.create((theme) => ({
flexWrap: "wrap", flexWrap: "wrap",
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
reconnectBanner: {
minHeight: 32,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
},
reconnectText: {
minWidth: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
flexShrink: 1,
},
reconnectTextDestructive: {
minWidth: 0,
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
flexShrink: 1,
},
})); }));

View File

@@ -129,10 +129,7 @@ import {
resolveWorkspaceRouteState, resolveWorkspaceRouteState,
type WorkspaceRouteState, type WorkspaceRouteState,
} from "@/screens/workspace/workspace-route-state"; } from "@/screens/workspace/workspace-route-state";
import { import { renderWorkspaceRouteGate } from "@/screens/workspace/workspace-route-state-views";
renderWorkspaceReconnectIndicator,
renderWorkspaceRouteGate,
} from "@/screens/workspace/workspace-route-state-views";
import { import {
deriveWorkspaceAgentVisibility, deriveWorkspaceAgentVisibility,
workspaceAgentVisibilityEqual, workspaceAgentVisibilityEqual,
@@ -2929,10 +2926,6 @@ function WorkspaceScreenContent({
gate: workspaceScreenGate, gate: workspaceScreenGate,
workspaceKey: persistenceKey, workspaceKey: persistenceKey,
}); });
const reconnectBanner = renderWorkspaceReconnectIndicator({
state: workspaceRouteState,
onRetryHost: handleRetryHost,
});
const headerRight = useMemo( const headerRight = useMemo(
() => ( () => (
@@ -3175,7 +3168,6 @@ function WorkspaceScreenContent({
/> />
<View style={styles.threePaneRow}> <View style={styles.threePaneRow}>
<View style={styles.centerColumn}> <View style={styles.centerColumn}>
{reconnectBanner}
{showScreenHeader && ( {showScreenHeader && (
<ScreenHeader <ScreenHeader
onRowLayout={onHeaderLayout} onRowLayout={onHeaderLayout}