mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -73,6 +73,20 @@ only use local param fallback during cold mount (`/` or empty pathname), or a
|
||||
hidden workspace can overwrite the remembered workspace before Settings or
|
||||
History returns.
|
||||
|
||||
## Agent Targets
|
||||
|
||||
Notifications and agent URLs enter the router with different authoritative
|
||||
targets.
|
||||
|
||||
- Notifications carry `serverId`, `workspaceId`, and `agentId`. Route them
|
||||
directly to the workspace with the agent open intent.
|
||||
- Agent URLs carry only `serverId` and `agentId`. Route them through
|
||||
`/h/[serverId]/agent/[agentId]`; that route waits for the named host, resolves
|
||||
the agent's workspace from the host, and then opens the agent there.
|
||||
|
||||
Both paths converge on `navigateToAgent()`. Do not make notification routing
|
||||
guess a workspace, and do not add a workspace to the stable agent URL format.
|
||||
|
||||
## Params
|
||||
|
||||
Required dynamic params belong to the matched route.
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostWorkspaceOpenRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import {
|
||||
@@ -34,6 +38,7 @@ import { getServerId } from "./helpers/server-id";
|
||||
import { injectDesktopBridge, waitForDesktopDaemonStartRequest } from "./helpers/desktop-updates";
|
||||
import { expectAppRoute } from "./helpers/route-assertions";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
import { addOfflineHostAndReload } from "./helpers/hosts";
|
||||
|
||||
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
||||
type StartupPresentation = "splash" | "app";
|
||||
@@ -158,6 +163,43 @@ async function expectWorkspaceLocation(
|
||||
test.describe("Workspace navigation regression", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("opens a notification's workspace on a different offline host", async ({ page }) => {
|
||||
const target = {
|
||||
serverId: "notification-offline-host",
|
||||
workspaceId: "notification-workspace",
|
||||
agentId: "notification-agent",
|
||||
};
|
||||
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, {
|
||||
serverId: target.serverId,
|
||||
label: "Notification Host",
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId("sidebar-settings").filter({ visible: true }).first(),
|
||||
).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await page.evaluate((data) => {
|
||||
globalThis.dispatchEvent(
|
||||
new CustomEvent("paseo:web-notification-click", {
|
||||
detail: { data: { ...data, reason: "finished" } },
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
}, target);
|
||||
|
||||
await expectAppRoute(
|
||||
page,
|
||||
buildHostWorkspaceOpenRoute(target.serverId, target.workspaceId, `agent:${target.agentId}`),
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(page.getByText("Connecting", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Notification Host", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("keeps one replacement draft after returning from settings and closing the last tab", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
@@ -420,12 +462,13 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expectWorkspaceDeckEntryCount(page, 2);
|
||||
|
||||
await page.evaluate(
|
||||
({ agentId, serverId: targetServerId }) => {
|
||||
({ agentId, serverId: targetServerId, workspaceId }) => {
|
||||
globalThis.dispatchEvent(
|
||||
new CustomEvent("paseo:web-notification-click", {
|
||||
detail: {
|
||||
data: {
|
||||
serverId: targetServerId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
reason: "finished",
|
||||
},
|
||||
@@ -434,7 +477,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ agentId: secondAgent.id, serverId },
|
||||
{ agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId },
|
||||
);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
if (!serverId || !agentId) {
|
||||
redirectedRef.current = true;
|
||||
router.replace("/" as Href);
|
||||
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 (!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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
147
packages/app/src/navigation/agent-route-resolution-view.tsx
Normal file
147
packages/app/src/navigation/agent-route-resolution-view.tsx
Normal file
@@ -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],
|
||||
},
|
||||
}));
|
||||
85
packages/app/src/navigation/agent-route-resolution.test.ts
Normal file
85
packages/app/src/navigation/agent-route-resolution.test.ts
Normal file
@@ -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" });
|
||||
});
|
||||
});
|
||||
53
packages/app/src/navigation/agent-route-resolution.ts
Normal file
53
packages/app/src/navigation/agent-route-resolution.ts
Normal file
@@ -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" };
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -6,10 +6,27 @@ import {
|
||||
} from "./agent-attention-notification.js";
|
||||
|
||||
describe("buildAgentAttentionNotificationPayload", () => {
|
||||
it("carries the workspace needed to open a cold agent destination", () => {
|
||||
const payload = buildAgentAttentionNotificationPayload({
|
||||
reason: "finished",
|
||||
serverId: "srv-1",
|
||||
workspaceId: "workspace-1",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
|
||||
expect(payload.data).toEqual({
|
||||
serverId: "srv-1",
|
||||
workspaceId: "workspace-1",
|
||||
agentId: "agent-1",
|
||||
reason: "finished",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds finished notifications from markdown assistant text", () => {
|
||||
const payload = buildAgentAttentionNotificationPayload({
|
||||
reason: "finished",
|
||||
serverId: "srv-1",
|
||||
workspaceId: "workspace-1",
|
||||
agentId: "agent-1",
|
||||
assistantMessage: "**Done**. Updated `README.md` and [link](https://example.com).",
|
||||
});
|
||||
@@ -19,6 +36,7 @@ describe("buildAgentAttentionNotificationPayload", () => {
|
||||
body: "Done. Updated README.md and link.",
|
||||
data: {
|
||||
serverId: "srv-1",
|
||||
workspaceId: "workspace-1",
|
||||
agentId: "agent-1",
|
||||
reason: "finished",
|
||||
},
|
||||
@@ -29,6 +47,7 @@ describe("buildAgentAttentionNotificationPayload", () => {
|
||||
const payload = buildAgentAttentionNotificationPayload({
|
||||
reason: "permission",
|
||||
serverId: "srv-2",
|
||||
workspaceId: "workspace-2",
|
||||
agentId: "agent-2",
|
||||
permissionRequest: {
|
||||
id: "perm-1",
|
||||
@@ -45,6 +64,7 @@ describe("buildAgentAttentionNotificationPayload", () => {
|
||||
body: "Approve command - Run git push",
|
||||
data: {
|
||||
serverId: "srv-2",
|
||||
workspaceId: "workspace-2",
|
||||
agentId: "agent-2",
|
||||
reason: "permission",
|
||||
},
|
||||
@@ -55,6 +75,7 @@ describe("buildAgentAttentionNotificationPayload", () => {
|
||||
const payload = buildAgentAttentionNotificationPayload({
|
||||
reason: "error",
|
||||
serverId: "srv-3",
|
||||
workspaceId: "workspace-3",
|
||||
agentId: "agent-3",
|
||||
});
|
||||
|
||||
@@ -63,6 +84,7 @@ describe("buildAgentAttentionNotificationPayload", () => {
|
||||
body: "Encountered an error.",
|
||||
data: {
|
||||
serverId: "srv-3",
|
||||
workspaceId: "workspace-3",
|
||||
agentId: "agent-3",
|
||||
reason: "error",
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ export type AgentAttentionReason = "finished" | "error" | "permission";
|
||||
export interface AgentAttentionNotificationData {
|
||||
[key: string]: unknown;
|
||||
serverId: string;
|
||||
workspaceId?: string;
|
||||
agentId: string;
|
||||
reason: AgentAttentionReason;
|
||||
}
|
||||
@@ -18,6 +19,7 @@ export interface AgentAttentionNotificationPayload {
|
||||
interface BuildAgentAttentionNotificationPayloadInput {
|
||||
reason: AgentAttentionReason;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
assistantMessage?: string | null;
|
||||
permissionRequest?: NotificationPermissionRequest | null;
|
||||
@@ -203,6 +205,7 @@ export function buildAgentAttentionNotificationPayload(
|
||||
body,
|
||||
data: {
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
agentId: input.agentId,
|
||||
reason: input.reason,
|
||||
},
|
||||
|
||||
@@ -7,18 +7,24 @@ function normalizeSegment(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function buildAgentDeepLink(target: AgentDeepLinkTarget): string {
|
||||
function normalizeAgentDeepLinkTarget(target: AgentDeepLinkTarget): AgentDeepLinkTarget {
|
||||
const serverId = normalizeSegment(target.serverId);
|
||||
const agentId = normalizeSegment(target.agentId);
|
||||
if (!serverId || !agentId) {
|
||||
throw new Error("Agent deep links require a server ID and agent ID.");
|
||||
}
|
||||
return `paseo://h/${encodeURIComponent(serverId)}/agent/${encodeURIComponent(agentId)}`;
|
||||
return { serverId, agentId };
|
||||
}
|
||||
|
||||
export function buildAgentDeepLinkRoute(target: AgentDeepLinkTarget): string {
|
||||
const link = new URL(buildAgentDeepLink(target));
|
||||
return `/h${link.pathname}`;
|
||||
export function buildAgentDeepLinkRoute(
|
||||
target: AgentDeepLinkTarget,
|
||||
): `/h/${string}/agent/${string}` {
|
||||
const { serverId, agentId } = normalizeAgentDeepLinkTarget(target);
|
||||
return `/h/${encodeURIComponent(serverId)}/agent/${encodeURIComponent(agentId)}`;
|
||||
}
|
||||
|
||||
export function buildAgentDeepLink(target: AgentDeepLinkTarget): string {
|
||||
return `paseo:/${buildAgentDeepLinkRoute(target)}`;
|
||||
}
|
||||
|
||||
export function parseAgentDeepLink(input: string): AgentDeepLinkTarget | null {
|
||||
|
||||
@@ -658,6 +658,7 @@ export const AgentStreamEventPayloadSchema = z.discriminatedUnion("type", [
|
||||
body: z.string(),
|
||||
data: z.object({
|
||||
serverId: z.string(),
|
||||
workspaceId: z.string().optional(),
|
||||
agentId: z.string(),
|
||||
reason: z.enum(["finished", "error", "permission"]),
|
||||
}),
|
||||
@@ -3564,6 +3565,7 @@ export const AgentAttentionRequiredMessageSchema = z.object({
|
||||
body: z.string(),
|
||||
data: z.object({
|
||||
serverId: z.string(),
|
||||
workspaceId: z.string().optional(),
|
||||
agentId: z.string(),
|
||||
reason: z.enum(["finished", "error", "permission"]),
|
||||
}),
|
||||
|
||||
@@ -138,6 +138,65 @@ const SourceSchema = z.object({
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "dedicated attention message",
|
||||
message: {
|
||||
type: "agent_attention_required",
|
||||
payload: {
|
||||
agentId: "agent-1",
|
||||
reason: "finished",
|
||||
timestamp: "2026-07-22T18:00:00.000Z",
|
||||
shouldNotify: true,
|
||||
notification: {
|
||||
title: "Agent finished",
|
||||
body: "Done",
|
||||
data: {
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-1",
|
||||
agentId: "agent-1",
|
||||
reason: "finished",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "agent stream attention event",
|
||||
message: {
|
||||
type: "agent_stream",
|
||||
payload: {
|
||||
agentId: "agent-1",
|
||||
timestamp: "2026-07-22T18:00:00.000Z",
|
||||
event: {
|
||||
type: "attention_required",
|
||||
provider: "codex",
|
||||
reason: "finished",
|
||||
timestamp: "2026-07-22T18:00:00.000Z",
|
||||
shouldNotify: true,
|
||||
notification: {
|
||||
title: "Agent finished",
|
||||
body: "Done",
|
||||
data: {
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-1",
|
||||
agentId: "agent-1",
|
||||
reason: "finished",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])("preserves workspaceId in a $name", ({ message }) => {
|
||||
const envelope = { type: "session", message };
|
||||
|
||||
expect(GeneratedWSOutboundMessageSchema.safeParse(envelope)).toEqual({
|
||||
success: true,
|
||||
data: envelope,
|
||||
});
|
||||
});
|
||||
|
||||
it("emits runtime imports with .js extensions", async () => {
|
||||
const generated = await readFile(generatedWSOutboundPath, "utf8");
|
||||
expect(generated).toContain('from "../../validation/ws-outbound-schema-metadata.js"');
|
||||
|
||||
@@ -14,6 +14,8 @@ import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js
|
||||
import type { PushNotificationSender, PushPayload } from "./push/notifications.js";
|
||||
import type { WorkspaceAutoName } from "./workspace-auto-name.js";
|
||||
|
||||
const WORKSPACE_ID = "workspace-1";
|
||||
|
||||
const wsModuleMock = vi.hoisted(() => {
|
||||
class MockWebSocketServer {
|
||||
readonly handlers = new Map<string, (...args: unknown[]) => void>();
|
||||
@@ -86,7 +88,7 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
||||
const agentManager = {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
setAgentAttentionCallback: vi.fn(),
|
||||
getAgent: vi.fn(() => null),
|
||||
getAgent: vi.fn(() => ({ workspaceId: WORKSPACE_ID, pendingPermissions: new Map() })),
|
||||
getLastAssistantMessage: vi.fn(async () => null),
|
||||
getMetricsSnapshot: vi.fn(() => ({
|
||||
total: 0,
|
||||
@@ -225,6 +227,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
|
||||
getAgent: vi.fn(() => ({
|
||||
config: { title: null },
|
||||
cwd: "/tmp/worktree",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
pendingPermissions: new Map(),
|
||||
})),
|
||||
getLastAssistantMessage,
|
||||
@@ -242,6 +245,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
|
||||
body: "Done. Updated README.md and link.",
|
||||
data: {
|
||||
serverId: "srv-test",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
agentId: "agent-1",
|
||||
reason: "finished",
|
||||
},
|
||||
@@ -256,6 +260,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
|
||||
getAgent: vi.fn(() => ({
|
||||
config: { title: null },
|
||||
cwd: "/tmp/worktree",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
labels: {},
|
||||
pendingPermissions: new Map(),
|
||||
})),
|
||||
|
||||
@@ -2152,13 +2152,17 @@ export class VoiceAssistantWebSocketServer {
|
||||
const allStates = clientEntries.map((e) => e.state);
|
||||
const nowMs = Date.now();
|
||||
const agent = this.agentManager.getAgent(params.agentId);
|
||||
if (!agent?.workspaceId) {
|
||||
return;
|
||||
}
|
||||
const assistantMessage = await this.agentManager.getLastAssistantMessage(params.agentId);
|
||||
const notification = buildAgentAttentionNotificationPayload({
|
||||
reason: params.reason,
|
||||
serverId: this.serverId,
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: params.agentId,
|
||||
assistantMessage,
|
||||
permissionRequest: agent ? findLatestPermissionRequest(agent.pendingPermissions) : null,
|
||||
permissionRequest: findLatestPermissionRequest(agent.pendingPermissions),
|
||||
});
|
||||
|
||||
const plan = computeNotificationPlan({
|
||||
|
||||
Reference in New Issue
Block a user