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
@@ -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" };
}