mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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;
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user