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:
@@ -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" };
|
||||
}
|
||||
Reference in New Issue
Block a user