Fix notifications opening the wrong workspace (#2331)

* fix(app): open notifications on the right host

Agent notifications previously omitted workspace ownership, so a cold target host was treated as missing and fell back to its empty home route. Carry the authoritative workspace and keep older notifications on a target-host resolver until lookup is conclusive.

* fix(app): separate notification and agent URL routing

Notifications use their authoritative workspace target directly. Stable agent URLs remain server-and-agent targets whose workspace is resolved by the agent route.

* fix(protocol): preserve notification workspace targets

Both attention message variants retain workspaceId through outbound validation so notification clicks receive the authoritative route target.

* test(app): use workspace-scoped notification target

* test(server): give notification agents workspace targets

* test(app): target notification workspace in navigation e2e
This commit is contained in:
Mohamed Boudra
2026-07-23 03:32:28 +08:00
committed by GitHub
parent de69b2a2af
commit 5dfe50ca74
19 changed files with 627 additions and 110 deletions
+3 -2
View File
@@ -147,9 +147,10 @@ function PushNotificationRouter() {
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
const target = resolveNotificationTarget(data);
const serverId = target.serverId;
const workspaceId = target.workspaceId;
const agentId = target.agentId;
if (serverId && agentId) {
navigateToAgent({ serverId, agentId, pin: true });
if (serverId && workspaceId && agentId) {
navigateToAgent({ serverId, workspaceId, agentId, pin: true });
return;
}
@@ -1,10 +1,13 @@
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useFetchQuery } from "@/data/query";
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { toErrorMessage } from "@/utils/error-messages";
import { navigateToAgent } from "@/utils/navigate-to-agent";
export default function HostAgentReadyRoute() {
@@ -21,97 +24,128 @@ function HostAgentReadyRouteContent() {
serverId?: string;
agentId?: string;
}>();
const redirectedRef = useRef(false);
const handledNavigationRef = useRef<string | null>(null);
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const agentId = typeof params.agentId === "string" ? params.agentId : "";
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const hosts = useHosts();
const runtimeSnapshot = useHostRuntimeSnapshot(serverId);
const client = runtimeSnapshot?.client ?? null;
const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting";
const hostName = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const agentWorkspaceId = useSessionStore((state) => {
if (!serverId || !agentId) {
return null;
}
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
});
const hasHydratedWorkspaces = useSessionStore((state) =>
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
const shouldLookupAgent = Boolean(
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
);
const resolvedWorkspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
const lookupQuery = useFetchQuery({
queryKey: ["agentRouteResolution", serverId, agentId, runtimeSnapshot?.clientGeneration ?? 0],
queryFn: async () => {
if (!client) {
throw new Error("Target host client is unavailable");
}
const result = await client.fetchAgent({ agentId });
return result?.agent?.workspaceId ?? null;
},
enabled: shouldLookupAgent,
retry: false,
dataShape: "value",
staleTimeMs: 0,
});
const lookup = useMemo<AgentRouteLookup>(() => {
if (!shouldLookupAgent) {
return { kind: "idle" };
}
if (lookupQuery.isFetching) {
return { kind: "fetching" };
}
if (lookupQuery.isError) {
return { kind: "failed", error: toErrorMessage(lookupQuery.error) };
}
if (lookupQuery.isSuccess) {
return { kind: "found", workspaceId: lookupQuery.data };
}
return { kind: "fetching" };
}, [
lookupQuery.data,
lookupQuery.error,
lookupQuery.isError,
lookupQuery.isFetching,
lookupQuery.isSuccess,
shouldLookupAgent,
]);
const resolution = resolveAgentRoute({
serverId,
agentId,
cachedWorkspaceId: agentWorkspaceId,
connectionStatus,
lookup,
});
useEffect(() => {
if (redirectedRef.current) {
return;
let navigationKey: string | null = null;
if (resolution.kind === "invalid") {
navigationKey = "invalid";
} else if (resolution.kind === "resolved") {
navigationKey = `workspace:${resolution.workspaceId}`;
} else if (resolution.kind === "notFound") {
navigationKey = "not-found";
}
if (!serverId || !agentId) {
redirectedRef.current = true;
router.replace("/" as Href);
if (!navigationKey || handledNavigationRef.current === navigationKey) {
return;
}
handledNavigationRef.current = navigationKey;
if (resolvedWorkspaceId) {
redirectedRef.current = true;
navigateToAgent({
serverId,
agentId,
});
if (resolution.kind === "resolved") {
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
return;
}
}, [agentId, resolvedWorkspaceId, router, serverId]);
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
}, [agentId, resolution, router, serverId]);
useEffect(() => {
if (redirectedRef.current) {
const handleRetry = useCallback(() => {
if (resolution.kind === "lookupError") {
void lookupQuery.refetch();
return;
}
if (!serverId || !agentId) {
if (serverId) {
void getHostRuntimeStore().runProbeCycleNow(serverId);
}
}, [lookupQuery, resolution.kind, serverId]);
const handleManageHost = useCallback(() => {
if (serverId) {
router.push(buildSettingsHostRoute(serverId));
}
}, [router, serverId]);
const handleBack = useCallback(() => {
if (router.canGoBack()) {
router.back();
return;
}
if (agentWorkspaceId && !hasHydratedWorkspaces) {
return;
}
if (!client || !isConnected) {
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId));
}
}, [agentWorkspaceId, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
router.replace(serverId ? buildHostRootRoute(serverId) : ("/" as Href));
}, [router, serverId]);
useEffect(() => {
if (redirectedRef.current) {
return;
}
if (!serverId || !agentId || !client || !isConnected) {
return;
}
let cancelled = false;
void client
.fetchAgent({ agentId })
.then((result) => {
if (cancelled || redirectedRef.current) {
return;
}
const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId);
redirectedRef.current = true;
if (workspaceId) {
navigateToAgent({
serverId,
agentId,
workspaceId,
});
return;
}
router.replace(buildHostRootRoute(serverId));
return;
})
.catch(() => {
if (cancelled || redirectedRef.current) {
return;
}
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId));
});
return () => {
cancelled = true;
};
}, [agentId, client, isConnected, router, serverId]);
if (
resolution.kind === "waitingForHost" ||
resolution.kind === "fetchingAgent" ||
resolution.kind === "lookupError"
) {
// Agent URLs intentionally omit workspaceId. Keep this route mounted while the target host
// reconnects, then resolve the workspace from the authoritative agent record.
return (
<AgentRouteResolutionView
resolution={resolution}
hostName={hostName}
lastHostError={runtimeSnapshot?.lastError ?? null}
onRetry={handleRetry}
onManageHost={handleManageHost}
onBack={handleBack}
/>
);
}
return null;
}
+43 -9
View File
@@ -29,6 +29,7 @@ import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol
import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages";
import {
buildAgentAttentionNotificationPayload,
type AgentAttentionReason,
type AgentAttentionNotificationPayload,
type NotificationPermissionRequest,
} from "@getpaseo/protocol/agent-attention-notification";
@@ -154,6 +155,35 @@ const getLatestPermissionRequest = (
return null;
};
interface AgentAttentionNotificationInput {
notification?: AgentAttentionNotificationPayload;
reason: AgentAttentionReason;
serverId: string;
workspaceId: string | undefined;
agentId: string;
assistantMessage: string | null;
permissionRequest: NotificationPermissionRequest | null;
}
function resolveAgentAttentionNotification(
input: AgentAttentionNotificationInput,
): AgentAttentionNotificationPayload | null {
if (input.notification) {
return input.notification.data.workspaceId ? input.notification : null;
}
if (!input.workspaceId) {
return null;
}
return buildAgentAttentionNotificationPayload({
reason: input.reason,
serverId: input.serverId,
workspaceId: input.workspaceId,
agentId: input.agentId,
assistantMessage: input.reason === "finished" ? input.assistantMessage : null,
permissionRequest: input.reason === "permission" ? input.permissionRequest : null,
});
}
type WorkspaceSetupProgressPayload = Extract<
SessionOutboundMessage,
{ type: "workspace_setup_progress" }
@@ -500,16 +530,20 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const assistantMessage =
findLatestAssistantMessageText(head) ?? findLatestAssistantMessageText(tail);
const permissionRequest = getLatestPermissionRequest(session, params.agentId);
const workspaceId = session?.agents?.get(params.agentId)?.workspaceId;
const notification =
params.notification ??
buildAgentAttentionNotificationPayload({
reason: params.reason,
serverId,
agentId: params.agentId,
assistantMessage: params.reason === "finished" ? assistantMessage : null,
permissionRequest: params.reason === "permission" ? permissionRequest : null,
});
const notification = resolveAgentAttentionNotification({
notification: params.notification,
reason: params.reason,
serverId,
workspaceId,
agentId: params.agentId,
assistantMessage,
permissionRequest,
});
if (!notification) {
return;
}
void sendOsNotification({
title: notification.title,
@@ -0,0 +1,147 @@
import { Text, View } from "react-native";
import { ArrowLeftToLine, RotateCw, Settings } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { AgentRouteResolution } from "@/navigation/agent-route-resolution";
import { formatConnectionStatus } from "@/utils/daemons";
import type { Theme } from "@/styles/theme";
type VisibleAgentRouteResolution = Extract<
AgentRouteResolution,
{ kind: "waitingForHost" | "fetchingAgent" | "lookupError" }
>;
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
export function AgentRouteResolutionView({
resolution,
hostName,
lastHostError,
onRetry,
onManageHost,
onBack,
}: {
resolution: VisibleAgentRouteResolution;
hostName: string;
lastHostError: string | null;
onRetry: () => void;
onManageHost: () => void;
onBack: () => void;
}) {
const { t } = useTranslation();
if (resolution.kind === "fetchingAgent") {
return (
<View style={styles.emptyState} testID="agent-route-fetching">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<View style={styles.textStack}>
<Text style={styles.title}>
{t("agentPanel.unavailable.preparingSession", { serverLabel: hostName })}
</Text>
<Text style={styles.description}>{t("agentPanel.unavailable.showSoon")}</Text>
</View>
</View>
);
}
if (resolution.kind === "lookupError") {
return (
<View style={styles.emptyState} testID="agent-route-lookup-error">
<View style={styles.textStack}>
<Text style={styles.title}>{t("agentPanel.states.failedToLoad")}</Text>
<Text style={styles.error}>{resolution.error}</Text>
</View>
<View style={styles.actions}>
<Button size="sm" variant="default" leftIcon={RotateCw} onPress={onRetry}>
{t("common.actions.retry")}
</Button>
<Button size="sm" variant="outline" leftIcon={ArrowLeftToLine} onPress={onBack}>
{t("common.actions.back")}
</Button>
</View>
</View>
);
}
const isConnecting =
resolution.connectionStatus === "connecting" || resolution.connectionStatus === "idle";
let title = t("workspace.route.cannotReachHost", { hostName });
if (isConnecting) {
title = t("agentPanel.unavailable.connecting", { serverLabel: hostName });
} else if (resolution.connectionStatus === "offline") {
title = t("workspace.route.hostOffline", { hostName });
}
return (
<View style={styles.emptyState} testID="agent-route-waiting-for-host">
{isConnecting ? (
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
) : null}
<View style={styles.textStack}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.description}>
{isConnecting
? t("agentPanel.unavailable.showWhenOnline")
: t("workspace.route.hostStatus", {
status: formatConnectionStatus(resolution.connectionStatus),
})}
</Text>
{lastHostError ? <Text style={styles.error}>{lastHostError}</Text> : null}
</View>
{!isConnecting ? (
<View style={styles.actions}>
<Button size="sm" variant="default" leftIcon={RotateCw} onPress={onRetry}>
{t("common.actions.retry")}
</Button>
<Button size="sm" variant="outline" leftIcon={Settings} onPress={onManageHost}>
{t("workspace.route.manageHost")}
</Button>
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
emptyState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[6],
},
textStack: {
alignItems: "center",
gap: theme.spacing[2],
maxWidth: 520,
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
textAlign: "center",
},
description: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
error: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
lineHeight: Math.round(theme.fontSize.sm * 1.4),
textAlign: "center",
},
actions: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
flexWrap: "wrap",
gap: theme.spacing[2],
},
}));
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { resolveAgentRoute } from "@/navigation/agent-route-resolution";
const VALID_ROUTE = {
serverId: "server-1",
agentId: "agent-1",
cachedWorkspaceId: null,
} as const;
describe("resolveAgentRoute", () => {
it("opens a cached workspace without waiting for its host", () => {
expect(
resolveAgentRoute({
...VALID_ROUTE,
cachedWorkspaceId: "workspace-1",
connectionStatus: "offline",
lookup: { kind: "idle" },
}),
).toEqual({ kind: "resolved", workspaceId: "workspace-1" });
});
it.each(["idle", "connecting", "offline", "error"] as const)(
"waits for a %s target host instead of abandoning the agent",
(connectionStatus) => {
expect(
resolveAgentRoute({
...VALID_ROUTE,
connectionStatus,
lookup: { kind: "idle" },
}),
).toEqual({ kind: "waitingForHost", connectionStatus });
},
);
it("fetches the agent after its target host connects", () => {
expect(
resolveAgentRoute({
...VALID_ROUTE,
connectionStatus: "online",
lookup: { kind: "idle" },
}),
).toEqual({ kind: "fetchingAgent" });
});
it("opens the workspace returned by the target host", () => {
expect(
resolveAgentRoute({
...VALID_ROUTE,
connectionStatus: "online",
lookup: { kind: "found", workspaceId: "workspace-2" },
}),
).toEqual({ kind: "resolved", workspaceId: "workspace-2" });
});
it("abandons the agent only after the target host says it is missing", () => {
expect(
resolveAgentRoute({
...VALID_ROUTE,
connectionStatus: "online",
lookup: { kind: "found", workspaceId: null },
}),
).toEqual({ kind: "notFound" });
});
it("keeps lookup failures retryable", () => {
expect(
resolveAgentRoute({
...VALID_ROUTE,
connectionStatus: "online",
lookup: { kind: "failed", error: "connection closed" },
}),
).toEqual({ kind: "lookupError", error: "connection closed" });
});
it("rejects incomplete route parameters", () => {
expect(
resolveAgentRoute({
...VALID_ROUTE,
agentId: "",
connectionStatus: "online",
lookup: { kind: "idle" },
}),
).toEqual({ kind: "invalid" });
});
});
@@ -0,0 +1,53 @@
import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
export type AgentRouteLookup =
| { kind: "idle" }
| { kind: "fetching" }
| { kind: "found"; workspaceId: string | null | undefined }
| { kind: "failed"; error: string };
export type AgentRouteResolution =
| { kind: "invalid" }
| { kind: "resolved"; workspaceId: string }
| {
kind: "waitingForHost";
connectionStatus: Exclude<HostRuntimeConnectionStatus, "online">;
}
| { kind: "fetchingAgent" }
| { kind: "notFound" }
| { kind: "lookupError"; error: string };
export function resolveAgentRoute(input: {
serverId: string;
agentId: string;
cachedWorkspaceId: string | null | undefined;
connectionStatus: HostRuntimeConnectionStatus;
lookup: AgentRouteLookup;
}): AgentRouteResolution {
if (!input.serverId || !input.agentId) {
return { kind: "invalid" };
}
const cachedWorkspaceId = normalizeWorkspaceOpaqueId(input.cachedWorkspaceId);
if (cachedWorkspaceId) {
return { kind: "resolved", workspaceId: cachedWorkspaceId };
}
if (input.connectionStatus !== "online") {
return { kind: "waitingForHost", connectionStatus: input.connectionStatus };
}
if (input.lookup.kind === "found") {
const fetchedWorkspaceId = normalizeWorkspaceOpaqueId(input.lookup.workspaceId);
return fetchedWorkspaceId
? { kind: "resolved", workspaceId: fetchedWorkspaceId }
: { kind: "notFound" };
}
if (input.lookup.kind === "failed") {
return { kind: "lookupError", error: input.lookup.error };
}
return { kind: "fetchingAgent" };
}
+5 -1
View File
@@ -1,4 +1,5 @@
import { Buffer } from "buffer";
import { buildAgentDeepLinkRoute } from "@getpaseo/protocol/agent-deep-link";
type NullableString = string | null | undefined;
const BASE64_WORKSPACE_ID_PREFIX = "b64_";
@@ -389,7 +390,10 @@ export function buildHostAgentDetailRoute(serverId: string, agentId: string, wor
if (!normalizedServerId || !normalizedAgentId) {
return "/" as const;
}
return `${buildHostRootRoute(normalizedServerId)}/agent/${encodeSegment(normalizedAgentId)}` as const;
return buildAgentDeepLinkRoute({
serverId: normalizedServerId,
agentId: normalizedAgentId,
});
}
export function buildHostRootRoute(serverId: string) {
@@ -56,13 +56,11 @@ describe("buildNotificationRoute", () => {
agentId: "agent-1",
workspaceId: "ws-main",
}),
).toBe("/h/srv-1/agent/agent-1");
).toBe("/h/srv-1/workspace/ws-main?open=agent%3Aagent-1");
});
it("routes directly to server-scoped agent path when both ids are present", () => {
expect(buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1" })).toBe(
"/h/srv-1/agent/agent-1",
);
it("does not treat an incomplete notification as an agent URL", () => {
expect(buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1" })).toBe("/h/srv-1");
});
it("routes to the workspace terminal tab when workspace and terminal ids are present", () => {
@@ -98,8 +96,9 @@ describe("buildNotificationRoute", () => {
expect(
buildNotificationRoute({
serverId: "srv/with/slash",
workspaceId: "workspace-1",
agentId: "agent with space",
}),
).toBe("/h/srv%2Fwith%2Fslash/agent/agent%20with%20space");
).toBe("/h/srv%2Fwith%2Fslash/workspace/workspace-1?open=agent%3Aagent%20with%20space");
});
});
@@ -1,9 +1,5 @@
import type { Href } from "expo-router";
import {
buildHostAgentDetailRoute,
buildHostRootRoute,
buildHostWorkspaceOpenRoute,
} from "@/utils/host-routes";
import { buildHostRootRoute, buildHostWorkspaceOpenRoute } from "@/utils/host-routes";
type NotificationData = Record<string, unknown> | null | undefined;
type NotificationRoute = Extract<Href, string>;
@@ -33,8 +29,8 @@ export function resolveNotificationTarget(data: NotificationData): {
export function buildNotificationRoute(data: NotificationData): NotificationRoute {
const { serverId, agentId, workspaceId, terminalId } = resolveNotificationTarget(data);
if (serverId && agentId) {
return buildHostAgentDetailRoute(serverId, agentId);
if (serverId && workspaceId && agentId) {
return buildHostWorkspaceOpenRoute(serverId, workspaceId, `agent:${agentId}`);
}
if (serverId && workspaceId && terminalId) {
return buildHostWorkspaceOpenRoute(serverId, workspaceId, `terminal:${terminalId}`);
@@ -196,14 +196,20 @@ describe("sendOsNotification", () => {
await sendOsNotification({
title: "Agent finished",
data: { serverId: "srv with space", agentId: "agent/1" },
data: {
serverId: "srv with space",
workspaceId: "workspace-1",
agentId: "agent/1",
},
});
const clicked = created[0];
expect(clicked.clickListeners).toHaveLength(1);
clicked.clickListeners[0]?.({} as Event);
expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1");
expect(assign).toHaveBeenCalledWith(
"/h/srv%20with%20space/workspace/workspace-1?open=agent%3Aagent%2F1",
);
});
it("returns false when the Notification API is unavailable", async () => {