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

View File

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

View File

@@ -708,7 +708,10 @@ function ChatAgentContent({
useEffect(() => {
if (connectionStatus === "online") {
reconnectToastArmedRef.current = false;
if (reconnectToastArmedRef.current) {
reconnectToastArmedRef.current = false;
panelToast.dismiss();
}
return;
}
if (connectionStatus === "idle") {
@@ -717,11 +720,11 @@ function ChatAgentContent({
if (!reconnectToastArmedRef.current) {
reconnectToastArmedRef.current = true;
panelToast.api.show("Reconnecting...", {
durationMs: 2200,
durationMs: null,
testID: "agent-reconnecting-toast",
});
}
}, [connectionStatus, panelToast.api]);
}, [connectionStatus, panelToast]);
useEffect(() => {
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(
state: Extract<WorkspaceRouteState, { kind: "unreachable" }>,
): string {
@@ -63,15 +53,6 @@ function getWorkspaceHostStateTitle(
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 }) {
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 }) {
return (
<View style={styles.emptyState}>
@@ -220,28 +174,4 @@ const styles = StyleSheet.create((theme) => ({
flexWrap: "wrap",
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,
type WorkspaceRouteState,
} from "@/screens/workspace/workspace-route-state";
import {
renderWorkspaceReconnectIndicator,
renderWorkspaceRouteGate,
} from "@/screens/workspace/workspace-route-state-views";
import { renderWorkspaceRouteGate } from "@/screens/workspace/workspace-route-state-views";
import {
deriveWorkspaceAgentVisibility,
workspaceAgentVisibilityEqual,
@@ -2929,10 +2926,6 @@ function WorkspaceScreenContent({
gate: workspaceScreenGate,
workspaceKey: persistenceKey,
});
const reconnectBanner = renderWorkspaceReconnectIndicator({
state: workspaceRouteState,
onRetryHost: handleRetryHost,
});
const headerRight = useMemo(
() => (
@@ -3175,7 +3168,6 @@ function WorkspaceScreenContent({
/>
<View style={styles.threePaneRow}>
<View style={styles.centerColumn}>
{reconnectBanner}
{showScreenHeader && (
<ScreenHeader
onRowLayout={onHeaderLayout}