Compare commits

...
Author SHA1 Message Date
Mohamed BoudraandKassam Khoja 12e7a448f0 fix(app): open notification agents once in their current workspace (#7612)
* fix(app): open notification agents once in their current workspace

Keep tap ownership across Activity and React remounts, clear native responses, and resolve agent workspaces through existing navigation after startup settles.

Co-authored-by: Kassam Khoja <kassam@shopwrk.com>

* fix(app): hydrate agent targets with the compact sidebar closed

Hold directory demand for the agent route while resolving its workspace; the compact sidebar does not supply demand before it opens.

* fix(app): keep deferred workspace opens in the layout model

Key pending tab opens by workspace so redirects and retained screens cannot lose or consume another workspace's target. Legacy open URLs seed this state at their route boundary.

* docs(app): document notification tap ownership and native replay

* fix(ci): route feature-owned Electron sources to desktop checks

* refactor(app): separate notification API from React integration

* fix(app): consume pending opens before painting the restored tab

A legacy open link showed the previously focused terminal for a frame before switching to the requested terminal. Seed and consume the pending open in layout effects so a ready target switches before paint.

* fix(e2e): focus the visible retained terminal surface

---------

Co-authored-by: Kassam Khoja <kassam@shopwrk.com>
2026-10-22 14:41:38 +07:00
34 changed files with 780 additions and 442 deletions
+1
View File
@@ -41,6 +41,7 @@ server:
desktop:
- "packages/desktop/**"
- "packages/app/src/desktop/**"
- "packages/app/src/**/*.electron.{ts,tsx}"
- "packages/server/src/server/browser-tools/**"
- "packages/app/e2e/support/**"
- "packages/app/*config.{cjs,js,ts}"
+1
View File
@@ -36,6 +36,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/notifications.md](docs/notifications.md) | Notification tap ownership, native replay and acknowledgement, startup handoff, and payload QA |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
+16 -9
View File
@@ -104,17 +104,24 @@ Do not collapse this topology with a catch-all, `getId`, or
## Agent Targets
Notifications and agent URLs enter the router with different authoritative
targets.
Notifications and agent URLs name a host and agent. Both use
`navigateToAgent()`, which reads the agent's current workspace from the store
or routes through `/h/[serverId]/agent/[agentId]`. The agent route waits for
the named host, demands its directory, and waits for workspace and layout
hydration before opening the tab. Keep the stable agent URL free of a workspace
hint. [Notifications](notifications.md) owns tap delivery and native lifecycle
constraints.
- 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.
When a workspace target cannot open yet, `navigateToWorkspace()` records a
pending open in the layout model under that host and workspace key. The owning
workspace screen consumes it once after hydration. Archived-workspace recovery
reads the same pending target, so startup redirects cannot discard it and a
retained screen cannot consume another workspace's request.
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.
Legacy `?open=` links seed this model state at the matched route boundary and
then lose the query parameter. Internal navigation does not use the query as a
handoff. Read matched route params locally; global search params let retained
workspace screens observe requests for other workspaces.
## Params
+71
View File
@@ -0,0 +1,71 @@
# Notifications
Use `notification-open` for OS notification taps. It owns delivery, parsing,
process-local deduplication, startup readiness, and native acknowledgement.
Keep agent and workspace navigation in their existing navigation modules.
Import the pure API from `@/notification-open` and the app-shell component from
`@/notification-open/react`; senders must not load the React routing runtime.
Agent payloads name a host and agent. Ignore their workspace hint when opening:
the agent can move after a notification is sent. Use `navigateToAgent`, which
uses the current store record or the agent route to resolve the workspace from
the host. Terminal payloads name their workspace and use `navigateToWorkspace`.
See [agent targets and deferred opens](expo-router.md#agent-targets) for route
ownership and workspace recovery. Notification generation, presence, and push
eligibility remain daemon concerns.
## Native delivery
Keep the router instance at module scope. Android's `singleTask` Activity can
finish on Back while its JS context remains alive. A React ref is lost when the
root remounts; process-local handled IDs must survive that remount.
Clear `lastNotificationResponse` after dispatch. Reading a launch response does
not acknowledge it. Without clearing it, a later Activity launch can reopen the
old agent.
With expo-notifications 57, Android also replays its pending response list to
new native listeners. Clearing the last response does not drain that list.
Deduplication covers listener registration within one JS context. A development
JS reload creates another context and can replay a cold-launch response; do not
add persisted handled IDs to compensate. Recheck this limitation when upgrading
expo-notifications.
iOS drains pending responses when its delegate first handles them. Push data
read by Expo arrives under `userInfo.body`; simulator payloads must match:
```json
{
"aps": { "alert": { "title": "Agent finished", "body": "Open the agent" }, "sound": "default" },
"body": {
"serverId": "host",
"workspaceId": "workspace",
"agentId": "agent",
"reason": "finished"
}
}
```
An Android FCM launch intent needs `google.message_id` and a JSON `body` extra.
An arbitrary Activity launch with data extras does not exercise Expo's FCM path.
Foreground pushes remain in Notification Center without a banner or sound.
This preserves notifications sent with stale presence information.
## Startup and verification
Wait for the root navigator, host registry, remembered selection, and startup
redirects before handing off a tap. A compact agent route must demand the host's
directory itself; its sidebar can be closed for the entire cold launch.
Verify warm and cold taps, stale and missing workspace hints, two distinct taps,
and Android Back followed by launcher reopen or a different notification tap. Check the focused tab and every
workspace's saved layout, since a foreign pinned tab survives navigation.
Use the daemon's payload shape on both iOS and Android; a browser event alone
does not test native replay or acknowledgement.
Existing foreign pins belong to the reconciliation fix in
[#6900](https://github.com/getpaseo/paseo/issues/6900). Desktop main-process window
selection is tracked separately in
[#6875](https://github.com/getpaseo/paseo/issues/6875).
@@ -11,6 +11,7 @@ import {
expectWorkspaceTabVisible,
openWorkspaceWithAgents,
} from "../support/helpers/archive-tab";
import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
@@ -93,10 +94,20 @@ async function closeFirstVisibleDraftTab(page: Page): Promise<void> {
await closeButton.first().click();
}
async function openWorkspaceTabUrl(page: Page, route: string): Promise<void> {
await page.evaluate((url) => {
window.history.pushState(null, "", url);
window.dispatchEvent(new PopStateEvent("popstate"));
}, route);
await expectAppRoute(page, route.split("?")[0]);
}
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
test("opens a notification's workspace on a different offline host", async ({ page }) => {
test("keeps an offline notification on the agent route until the host connects", async ({
page,
}) => {
const target = {
serverId: "notification-offline-host",
workspaceId: "notification-workspace",
@@ -123,14 +134,33 @@ test.describe("Workspace navigation regression", () => {
);
}, 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);
await expectAppRoute(page, buildHostAgentDetailRoute(target.serverId, target.agentId), {
timeout: 30_000,
});
await expect(page.getByTestId("agent-route-waiting-for-host")).toBeVisible();
await expect(page.getByText(/Notification Host/).first()).toBeVisible();
});
test("cold agent targets hydrate their workspace without opening the compact sidebar", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "notification-cold-compact-" });
try {
const agent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Cold notification agent",
});
await page.setViewportSize({ width: 390, height: 844 });
await gotoAppShell(page);
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id));
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), workspace.workspaceId), {
timeout: 30_000,
});
await expectComposerVisible(page);
} finally {
await workspace.cleanup();
}
});
test("shows the New launcher after returning from settings and closing the last agent draft tab", async ({
@@ -321,7 +351,7 @@ test.describe("Workspace navigation regression", () => {
}
});
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
test("a stale notification workspace preserves retained workspace selection and tabs", async ({
page,
}) => {
const serverId = getServerId();
@@ -435,7 +465,7 @@ test.describe("Workspace navigation regression", () => {
}),
);
},
{ agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId },
{ agentId: secondAgent.id, serverId, workspaceId: firstWorkspace.workspaceId },
);
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
@@ -463,6 +493,22 @@ test.describe("Workspace navigation regression", () => {
await expect(secondDeckEntry).toBeHidden();
await expectWorkspaceDeckEntryCount(page, 2);
await expectAgentAbsentFromWorkspaceLayout(page, {
serverId,
workspaceId: firstWorkspace.workspaceId,
agentId: secondAgent.id,
});
await openWorkspaceTabUrl(
page,
buildHostWorkspaceOpenRoute(serverId, firstWorkspace.workspaceId, `agent:${firstAgent.id}`),
);
await expectWorkspaceTabVisible(page, firstAgent.id);
await expectAgentAbsentFromWorkspaceLayout(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
agentId: firstAgent.id,
});
await page.reload();
await waitForSidebarHydration(page);
await waitForWorkspaceTabsVisible(page);
@@ -68,8 +68,8 @@ export async function navigateToTerminal(
const workspaceRoute = buildTerminalWorkspaceUrl(input.workspaceId, input.terminalId);
await page.goto(workspaceRoute);
// The workspace layout consumes `?open=...`, returns null during the effect,
// then replaces the URL with the clean workspace route after preparing the tab.
// The route seeds the legacy URL target into the pending-open model, then
// removes the query. Tab readiness below waits for that intent to be consumed.
// On CI, Expo Router's rootNavigationState may take time to initialize,
// so we allow a generous timeout here.
const cleanWorkspaceRoute = buildWorkspaceUrl(input.workspaceId);
@@ -86,7 +86,8 @@ export async function navigateToTerminal(
await terminalTab.waitFor({ state: "visible", timeout: 30_000 });
await terminalTab.click();
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
// Inactive terminal tabs retain their surfaces; interact with the selected tab.
const terminalSurface = page.getByTestId("terminal-surface").filter({ visible: true });
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
// Wait for loading overlay to disappear (terminal attached)
@@ -1,4 +1,4 @@
import type { Page } from "@playwright/test";
import { expect, type Page } from "@playwright/test";
import { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
import {
@@ -61,3 +61,26 @@ export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: stri
JSON.stringify(stored),
);
}
export async function expectAgentAbsentFromWorkspaceLayout(
page: Page,
input: { serverId: string; workspaceId: string; agentId: string },
): Promise<void> {
await expect
.poll(async () => {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
if (!raw) throw new Error("Workspace layout was not persisted");
const stored = z
.object({ state: WorkspaceLayoutPersistedStateSchema })
.parse(JSON.parse(raw));
const key = `${input.serverId}:${input.workspaceId}`;
const layout = stored.state.layoutByWorkspace[key];
const pins = stored.state.pinnedAgentIdsByWorkspace?.[key] ?? [];
// The persisted tree is validated above; inspect all panes including hidden ones.
return {
containsAgent: JSON.stringify(layout).includes(input.agentId),
pinned: pins.includes(input.agentId),
};
})
.toEqual({ containsAgent: false, pinned: false });
}
+12 -124
View File
@@ -7,7 +7,6 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
import { LucideProvider } from "lucide-react-native";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
import {
createContext,
@@ -16,7 +15,6 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
@@ -51,11 +49,7 @@ import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-short
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
import { HostConfirmationSheet } from "@/hosts/host-confirmation-sheet";
import {
getIsElectronRuntime,
HEADER_INNER_HEIGHT,
useIsCompactFormFactor,
} from "@/constants/layout";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import {
canDesktopAppSidebarShare,
resolveDesktopAppChromeLayout,
@@ -97,7 +91,6 @@ import { KeyboardShiftProvider } from "@/keyboard/shift";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelsProvider, useIsMobilePanelActive } from "@/mobile-panels/provider";
import {
@@ -132,14 +125,9 @@ import {
parseHostWorkspaceRouteFromPathname,
parseServerIdFromPathname,
} from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { PluginCatalogSync } from "@/plugins";
import {
ensureOsNotificationPermission,
WEB_NOTIFICATION_CLICK_EVENT,
type WebNotificationClickDetail,
} from "@/utils/os-notifications";
import { NotificationOpenRouter } from "@/notification-open/react";
import { ensureOsNotificationPermission } from "@/utils/os-notifications";
polyfillCrypto();
@@ -159,114 +147,6 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
startupBlocker: { kind: "none" },
});
function PushNotificationRouter() {
const router = useRouter();
const lastHandledIdRef = useRef<string | null>(null);
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 && workspaceId && agentId) {
navigateToAgent({ serverId, workspaceId, agentId, pin: true });
return;
}
router.navigate(buildNotificationRoute(data));
});
useEffect(() => {
if (isWeb) {
let removeDesktopNotificationListener: (() => void) | null = null;
let cancelled = false;
if (getIsElectronRuntime()) {
void ensureOsNotificationPermission();
const unlistenResult = getDesktopHost()?.events?.on?.(
"notification-click",
(payload: unknown) => {
const data =
typeof payload === "object" &&
payload !== null &&
"data" in payload &&
typeof (payload as { data?: unknown }).data === "object" &&
(payload as { data?: unknown }).data !== null
? (payload as { data: Record<string, unknown> }).data
: undefined;
openNotification(data);
},
);
void Promise.resolve(unlistenResult).then((unlisten) => {
if (typeof unlisten !== "function") {
return;
}
if (cancelled) {
unlisten();
return;
}
removeDesktopNotificationListener = unlisten;
return;
});
}
const openFromWebClick = (event: Event) => {
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
event.preventDefault();
openNotification(customEvent.detail?.data);
};
window.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
return () => {
cancelled = true;
removeDesktopNotificationListener?.();
window.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
};
}
Notifications.setNotificationHandler({
handleNotification: async () => ({
// When the app is open, don't show OS banners.
shouldShowAlert: false,
shouldShowBanner: false,
shouldShowList: false,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
const openFromResponse = (response: Notifications.NotificationResponse) => {
const identifier = response.notification.request.identifier;
if (lastHandledIdRef.current === identifier) {
return;
}
lastHandledIdRef.current = identifier;
const data = response.notification.request.content.data as
| Record<string, unknown>
| undefined;
openNotification(data);
};
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
void Notifications.getLastNotificationResponseAsync().then((response) => {
if (response) {
openFromResponse(response);
}
return;
});
return () => {
subscription.remove();
};
}, [openNotification]);
return null;
}
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const client = useHostRuntimeClient(daemon.serverId);
@@ -945,6 +825,9 @@ function WorkspaceRouteNavigationBridge() {
}
function AppShell() {
useEffect(() => {
if (getDesktopHost()) void ensureOsNotificationPermission();
}, []);
return (
<MobilePanelsProvider>
<HorizontalScrollProvider>
@@ -959,10 +842,15 @@ function AppShell() {
);
}
function NotificationOpenBridge() {
const { startupBlocker } = useHostRuntimeBootstrapState();
return <NotificationOpenRouter startupBlocked={startupBlocker.kind !== "none"} />;
}
function RuntimeProviders({ children }: { children: ReactNode }) {
return (
<HostRuntimeBootstrapProvider>
<PushNotificationRouter />
<NotificationOpenBridge />
<SidebarCalloutProvider>
<ProvidersWrapper>{children}</ProvidersWrapper>
</SidebarCalloutProvider>
@@ -4,6 +4,8 @@ import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-bo
import { useFetchQuery } from "@/data/query";
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
import { useHasHydratedWorkspaces } from "@/stores/session-store-hooks";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import { useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
@@ -83,7 +85,16 @@ function HostAgentReadyRouteContent() {
lookupQuery.isSuccess,
shouldLookupAgent,
]);
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const hasHydratedLayout = useWorkspaceLayoutStoreHydrated();
useEffect(() => {
if (!serverId) return;
// A compact app can open this route before its sidebar has demanded the directory.
return getHostRuntimeStore().acquireDirectoryDemand(serverId);
}, [serverId]);
const resolution = resolveAgentRoute({
hasHydratedWorkspaces,
hasHydratedLayout,
serverId,
agentId,
cachedWorkspaceId: agentWorkspaceId,
@@ -106,7 +117,7 @@ function HostAgentReadyRouteContent() {
handledNavigationRef.current = navigationKey;
if (resolution.kind === "resolved") {
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId, pin: true });
return;
}
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
import { useNavigation } from "expo-router/react-navigation";
import { StyleSheet, View } from "react-native";
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
import { useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { RetainedPanel } from "@/components/retained-panel";
import {
@@ -11,7 +11,7 @@ import {
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import {
areRetainedWorkspaceSelectionListsEqual,
areWorkspaceSelectionsEqual,
@@ -33,7 +33,7 @@ import {
replaceBrowserRouteWithCanonicalHostWorkspaceRoute,
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit,
} from "@/utils/host-route-browser";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { prepareWorkspaceTab } from "@/utils/prepare-workspace-tab";
import { isNative, isWeb } from "@/constants/platform";
import { RenderProfile } from "@/utils/render-profiler";
@@ -64,6 +64,8 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
return { kind: "draft", draftId: openIntent.draftId };
}
// COMPAT(workspace-open-url): added in v0.12.0, remove after 2027-01-22.
// Bookmarks and older clients still send ?open=; internal navigation uses pending model state.
function stripOpenSearchParamFromBrowserUrl() {
if (!isWeb || typeof window === "undefined") {
return;
@@ -81,7 +83,9 @@ function clearConsumedOpenIntent(input: {
}) {
input.navigation.setParams({ open: undefined });
if (isWeb) {
stripOpenSearchParamFromBrowserUrl();
// Expo commits the incoming browser URL after setParams. Strip the consumed legacy input
// after that commit so it cannot be restored into the address bar and replayed on reload.
window.setTimeout(stripOpenSearchParamFromBrowserUrl, 0);
}
}
@@ -95,15 +99,9 @@ export default function HostWorkspaceIndexRoute() {
function HostWorkspaceRouteContent() {
const navigation = useNavigation();
const rootNavigationState = useRootNavigationState();
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
const consumedIntentRef = useRef<string | null>(null);
const [intentConsumed, setIntentConsumed] = useState(false);
const params = useLocalSearchParams<{
serverId?: string | string[];
workspaceId?: string | string[];
}>();
const globalParams = useGlobalSearchParams<{
open?: string | string[];
}>();
const serverId = getParamValue(params.serverId);
@@ -111,14 +109,7 @@ function HostWorkspaceRouteContent() {
const workspaceId = workspaceValue
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
const openValue = getParamValue(globalParams.open);
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
const isAgentOpenIntent = openIntent?.kind === "agent";
const isOpenIntentWaitingForWorkspace = Boolean(
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
);
const openValue = getParamValue(params.open);
useEffect(() => {
if (!serverId || !workspaceId) {
return;
@@ -126,74 +117,31 @@ function HostWorkspaceRouteContent() {
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit();
}, [serverId, workspaceId]);
useEffect(() => {
if (!openValue) {
return;
}
if (!rootNavigationState?.key) {
return;
}
if (!hasHydratedWorkspaceLayoutStore) {
return;
}
if (isOpenIntentWaitingForWorkspace) {
return;
}
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) {
clearConsumedOpenIntent({
navigation: navigation as unknown as {
setParams: (params: { open?: string | undefined }) => void;
},
});
setIntentConsumed(true);
return;
}
consumedIntentRef.current = consumptionKey;
useLayoutEffect(() => {
if (!openValue || !serverId || !workspaceId) return;
const openIntent = parseWorkspaceOpenIntent(openValue);
if (openIntent) {
prepareWorkspaceTab({
serverId,
workspaceId,
target: getOpenIntentTarget(openIntent),
pin: openIntent.kind === "agent",
});
prepareWorkspaceTab(
{
serverId,
workspaceId,
target: getOpenIntentTarget(openIntent),
pin: openIntent.kind === "agent",
},
{ openTab: useWorkspaceLayoutStore.getState().deferOpenTab },
);
}
// Expo Router's replace ignores query-param-only changes (findDivergentState
// skips search params). Strip ?open from the browser URL directly so the
// address bar reflects the clean workspace route.
clearConsumedOpenIntent({
navigation: navigation as unknown as {
setParams: (params: { open?: string | undefined }) => void;
},
});
}, [navigation, openValue, serverId, workspaceId]);
setIntentConsumed(true);
}, [
hasHydratedWorkspaceLayoutStore,
isOpenIntentWaitingForWorkspace,
navigation,
openIntent,
openValue,
rootNavigationState?.key,
serverId,
workspaceId,
]);
if (
openValue &&
!isOpenIntentWaitingForWorkspace &&
(!intentConsumed || !hasHydratedWorkspaceLayoutStore)
) {
return null;
}
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
return <WorkspaceDeck />;
}
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
function WorkspaceDeck() {
const activeSelection = useActiveWorkspaceSelection();
const [retainedSelections, setRetainedSelections] = useState<RetainedWorkspaceSelection[]>(() =>
activeSelection ? [{ selection: activeSelection, inactiveSince: null }] : [],
@@ -264,7 +212,6 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
key={getWorkspaceSelectionKey(selection)}
selection={selection}
active={active}
recoveryRequested={recoveryRequested}
onUnmountInactive={unmountWorkspaceSelection}
/>
);
@@ -277,12 +224,10 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
function WorkspaceDeckEntry({
selection,
active,
recoveryRequested,
onUnmountInactive,
}: {
selection: ActiveWorkspaceSelection;
active: boolean;
recoveryRequested: boolean;
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
}) {
const hasHydratedWorkspaces = useHasHydratedWorkspaces(selection.serverId);
@@ -312,7 +257,6 @@ function WorkspaceDeckEntry({
serverId={selection.serverId}
workspaceId={selection.workspaceId}
isRouteFocused={active}
recoveryRequested={active && recoveryRequested}
/>
</RetainedPanel>
);
@@ -38,3 +38,5 @@ export function addNotificationResponseReceivedListener() {
export async function getLastNotificationResponseAsync() {
return null;
}
export function clearLastNotificationResponse(): void {}
@@ -5,6 +5,8 @@ const VALID_ROUTE = {
serverId: "server-1",
agentId: "agent-1",
cachedWorkspaceId: null,
hasHydratedWorkspaces: true,
hasHydratedLayout: true,
} as const;
describe("resolveAgentRoute", () => {
@@ -32,6 +34,20 @@ describe("resolveAgentRoute", () => {
},
);
it.each([
{ hasHydratedWorkspaces: false, hasHydratedLayout: true },
{ hasHydratedWorkspaces: true, hasHydratedLayout: false },
])("waits for workspace and layout hydration: %j", (hydration) => {
expect(
resolveAgentRoute({
...VALID_ROUTE,
...hydration,
connectionStatus: "online",
lookup: { kind: "found", workspaceId: "workspace-2" },
}),
).toEqual({ kind: "fetchingAgent" });
});
it("fetches the agent after its target host connects", () => {
expect(
resolveAgentRoute({
@@ -24,13 +24,18 @@ export function resolveAgentRoute(input: {
cachedWorkspaceId: string | null | undefined;
connectionStatus: HostRuntimeConnectionStatus;
lookup: AgentRouteLookup;
hasHydratedWorkspaces: boolean;
hasHydratedLayout: boolean;
}): AgentRouteResolution {
if (!input.serverId || !input.agentId) {
return { kind: "invalid" };
}
const hydrationPending = !input.hasHydratedWorkspaces || !input.hasHydratedLayout;
if (hydrationPending && input.connectionStatus === "online") return { kind: "fetchingAgent" };
const cachedWorkspaceId = normalizeWorkspaceOpaqueId(input.cachedWorkspaceId);
if (cachedWorkspaceId) {
if (cachedWorkspaceId && !hydrationPending) {
return { kind: "resolved", workspaceId: cachedWorkspaceId };
}
@@ -0,0 +1,6 @@
export {
createNotificationOpenRouter,
parseNotificationOpenTarget,
WEB_NOTIFICATION_CLICK_EVENT,
} from "./model";
export type { NotificationOpenSource, NotificationTap } from "./source";
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import { createNotificationOpenRouter, parseNotificationOpenTarget } from "./model";
import type { NotificationOpenSource, NotificationTap } from "./source";
class FakeSource implements NotificationOpenSource {
listeners = new Set<(tap: NotificationTap) => void>();
acknowledged: string[] = [];
launchReads = 0;
constructor(readonly launch: NotificationTap | null) {}
subscribe(onTap: (tap: NotificationTap) => void) {
this.listeners.add(onTap);
return () => {
this.listeners.delete(onTap);
};
}
async takeLaunchTap() {
this.launchReads++;
return this.launch;
}
acknowledge(tap: NotificationTap) {
this.acknowledged.push(tap.id);
}
tap(tap: NotificationTap) {
for (const listener of this.listeners) listener(tap);
}
}
const launch: NotificationTap = {
id: "one",
data: { serverId: "host", workspaceId: "stale", agentId: "agent" },
};
async function flush() {
for (let i = 0; i < 10; i++) await Promise.resolve();
}
describe("notification open", () => {
it.each([
[undefined, null],
[null, null],
[[], null],
[{ agentId: "a" }, null],
[{ serverId: " " }, null],
[{ serverId: " h " }, { kind: "host", serverId: "h" }],
[
{ serverId: "h", agentId: "a" },
{ kind: "agent", serverId: "h", agentId: "a" },
],
[
{ serverId: "h", workspaceId: "stale", agentId: "a" },
{ kind: "agent", serverId: "h", agentId: "a" },
],
[
{ serverId: "h", workspaceId: "w", terminalId: "t", agentId: "a" },
{ kind: "terminal", serverId: "h", workspaceId: "w", terminalId: "t" },
],
])("parses %j", (data, expected) => {
expect(parseNotificationOpenTarget(data)).toEqual(expected);
});
function setup(whenReadyToNavigate = () => Promise.resolve()) {
const source = new FakeSource(launch);
const agents: unknown[] = [];
const routes: string[] = [];
const terminals: unknown[] = [];
const router = createNotificationOpenRouter({
source,
openAgent: (target) => {
agents.push(target);
},
openTerminal: (target) => {
terminals.push(target);
},
openRoute: (route) => {
routes.push(route);
},
whenReadyToNavigate,
});
return { source, agents, routes, terminals, router };
}
it("takes the launch tap once across remounts", async () => {
const { source, agents, router } = setup();
router.start()();
const stop = router.start();
await flush();
expect(source.launchReads).toBe(1);
expect(agents).toEqual([{ serverId: "host", agentId: "agent" }]);
stop();
});
it("ignores listener replay of the launch tap after remount", async () => {
const { source, agents, router } = setup();
const stop = router.start();
await flush();
stop();
const stopAgain = router.start();
source.tap(launch);
await flush();
expect(agents).toEqual([{ serverId: "host", agentId: "agent" }]);
expect(source.acknowledged).toEqual(["one"]);
stopAgain();
});
it("waits for readiness before dispatch and acknowledgement", async () => {
let ready = () => {};
const readiness = new Promise<void>((resolve) => {
ready = resolve;
});
const { source, agents, router } = setup(() => readiness);
const stop = router.start();
await flush();
expect(agents).toEqual([]);
expect(source.acknowledged).toEqual([]);
ready();
await flush();
expect(agents).toEqual([{ serverId: "host", agentId: "agent" }]);
expect(source.acknowledged).toEqual(["one"]);
stop();
});
it("acknowledges each distinct tap including invalid targets", async () => {
const { source, agents, router } = setup();
const stop = router.start();
await flush();
source.tap({ id: "two", data: { serverId: "host", agentId: "second" } });
source.tap({ id: "invalid", data: undefined });
await flush();
expect(agents).toEqual([
{ serverId: "host", agentId: "agent" },
{ serverId: "host", agentId: "second" },
]);
expect(source.acknowledged).toEqual(["one", "two", "invalid"]);
stop();
});
it("dispatches terminals and hosts through their navigation capabilities", async () => {
const { source, routes, terminals, router } = setup();
const stop = router.start();
source.tap({
id: "terminal",
data: { serverId: "host", workspaceId: "ws", terminalId: "term" },
});
source.tap({ id: "host", data: { serverId: "host" } });
await flush();
expect(routes).toEqual(["/h/host"]);
expect(terminals).toEqual([
{ kind: "terminal", serverId: "host", workspaceId: "ws", terminalId: "term" },
]);
stop();
});
});
@@ -0,0 +1,67 @@
import { buildHostRootRoute } from "@/utils/host-routes";
import type { NotificationOpenSource, NotificationTap } from "./source";
export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
export type NotificationOpenTarget =
| { kind: "agent"; serverId: string; agentId: string }
| { kind: "terminal"; serverId: string; workspaceId: string; terminalId: string }
| { kind: "host"; serverId: string };
function readId(data: object, key: string): string {
const value: unknown = Reflect.get(data, key);
return typeof value === "string" ? value.trim() : "";
}
export function parseNotificationOpenTarget(data: unknown): NotificationOpenTarget | null {
if (typeof data !== "object" || data === null || Array.isArray(data)) return null;
const serverId = readId(data, "serverId");
if (!serverId) return null;
const workspaceId = readId(data, "workspaceId");
const terminalId = readId(data, "terminalId");
if (workspaceId && terminalId) return { kind: "terminal", serverId, workspaceId, terminalId };
const agentId = readId(data, "agentId");
if (agentId) return { kind: "agent", serverId, agentId };
return { kind: "host", serverId };
}
interface NotificationOpenRouterDeps {
source: NotificationOpenSource;
openAgent: (target: { serverId: string; agentId: string }) => void;
openRoute: (route: string) => void;
openTerminal: (target: Extract<NotificationOpenTarget, { kind: "terminal" }>) => void;
whenReadyToNavigate: () => Promise<void>;
}
export function createNotificationOpenRouter(deps: NotificationOpenRouterDeps) {
const handledIds = new Set<string>();
let launchTaken = false;
async function receive(tap: NotificationTap) {
if (handledIds.has(tap.id)) return;
handledIds.add(tap.id);
await deps.whenReadyToNavigate();
const target = parseNotificationOpenTarget(tap.data);
if (target?.kind === "agent") {
deps.openAgent({ serverId: target.serverId, agentId: target.agentId });
} else if (target?.kind === "terminal") {
deps.openTerminal(target);
} else if (target?.kind === "host") {
deps.openRoute(buildHostRootRoute(target.serverId));
}
deps.source.acknowledge(tap);
}
return {
start() {
const unsubscribe = deps.source.subscribe((tap) => {
void receive(tap);
});
if (!launchTaken) {
launchTaken = true;
void deps.source.takeLaunchTap().then((tap) => {
return tap ? receive(tap) : undefined;
});
}
return unsubscribe;
},
};
}
@@ -0,0 +1,58 @@
import { useEffect } from "react";
import { router, usePathname, useRootNavigationState, type Href } from "expo-router";
import { useHostRegistryStatus } from "@/runtime/host-runtime";
import {
navigateToWorkspace,
useIsLastWorkspaceSelectionHydrated,
} from "@/stores/navigation-active-workspace-store";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { createNotificationOpenRouter } from "../model";
import { createNotificationOpenSource } from "../source";
let ready = false;
const waiters = new Set<() => void>();
const notificationRouter = createNotificationOpenRouter({
source: createNotificationOpenSource(),
openAgent: (target) => navigateToAgent({ ...target, pin: true }),
openTerminal: ({ serverId, workspaceId, terminalId }) =>
navigateToWorkspace({
serverId,
workspaceId,
target: { kind: "terminal", terminalId },
}),
openRoute: (route) => router.navigate(route as Href),
whenReadyToNavigate: () =>
ready
? Promise.resolve()
: new Promise<void>((resolve) => {
waiters.add(resolve);
}),
});
export function NotificationOpenRouter({ startupBlocked }: { startupBlocked: boolean }) {
const root = useRootNavigationState();
const pathname = usePathname();
const registryStatus = useHostRegistryStatus();
const selectionHydrated = useIsLastWorkspaceSelectionHydrated();
// Index routes redirect during startup. Let their final leaf commit before dispatching a tap.
const atStartupIndex = pathname === "/" || /^\/h\/[^/]+\/?$/.test(pathname);
const canNavigate = Boolean(
root?.key &&
registryStatus === "ready" &&
selectionHydrated &&
!startupBlocked &&
!atStartupIndex,
);
useEffect(() => {
ready = canNavigate;
if (ready) {
for (const resolve of waiters) resolve();
waiters.clear();
}
return () => {
ready = false;
};
}, [canNavigate]);
useEffect(() => notificationRouter.start(), []);
return null;
}
@@ -0,0 +1,42 @@
import { getDesktopHost } from "@/desktop/host";
import { createNotificationOpenSource as createWebSource } from "./source.web";
import type { NotificationOpenSource } from "./source";
export function createNotificationOpenSource(): NotificationOpenSource {
const web = createWebSource();
let nextId = 0;
return {
...web,
subscribe(onTap) {
const stopWeb = web.subscribe(onTap);
let disposed = false;
let stopDesktop: (() => void) | undefined;
const subscription = getDesktopHost()?.events?.on?.(
"notification-click",
(payload: unknown) => {
const data =
typeof payload === "object" && payload !== null && "data" in payload
? payload.data
: undefined;
onTap({
id: `electron:${++nextId}`,
data:
typeof data === "object" && data !== null
? Object.fromEntries(Object.entries(data))
: undefined,
});
},
);
void Promise.resolve(subscription).then((stop) => {
if (disposed) stop?.();
else stopDesktop = stop;
return;
});
return () => {
disposed = true;
stopWeb();
stopDesktop?.();
};
},
};
}
@@ -0,0 +1,36 @@
import * as Notifications from "expo-notifications";
import type { NotificationOpenSource, NotificationTap } from "./source";
function toTap(response: Notifications.NotificationResponse): NotificationTap {
return {
id: response.notification.request.identifier,
data: response.notification.request.content.data,
};
}
export function createNotificationOpenSource(): NotificationOpenSource {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: false,
shouldShowBanner: false,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
return {
subscribe(onTap) {
const subscription = Notifications.addNotificationResponseReceivedListener((response) =>
onTap(toTap(response)),
);
return () => subscription.remove();
},
async takeLaunchTap() {
const response = await Notifications.getLastNotificationResponseAsync();
return response ? toTap(response) : null;
},
acknowledge() {
Notifications.clearLastNotificationResponse();
},
};
}
@@ -0,0 +1,12 @@
export interface NotificationTap {
id: string;
data: Record<string, unknown> | undefined;
}
export interface NotificationOpenSource {
subscribe(onTap: (tap: NotificationTap) => void): () => void;
takeLaunchTap(): Promise<NotificationTap | null>;
acknowledge(tap: NotificationTap): void;
}
export { createNotificationOpenSource } from "./source.native";
@@ -0,0 +1,23 @@
import { WEB_NOTIFICATION_CLICK_EVENT } from "./model";
import type { NotificationOpenSource } from "./source";
import { isWeb } from "@/constants/platform";
export function createNotificationOpenSource(): NotificationOpenSource {
let nextId = 0;
return {
subscribe(onTap) {
if (!isWeb) return () => {};
function receive(event: Event) {
if (!(event instanceof CustomEvent)) return;
event.preventDefault();
onTap({ id: `web:${++nextId}`, data: event.detail?.data });
}
window.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, receive);
return () => window.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, receive);
},
async takeLaunchTap() {
return null;
},
acknowledge() {},
};
}
@@ -93,6 +93,7 @@ import {
type SheetTab,
type WorkspaceTabPlacement,
useWorkspaceLayoutStore,
usePendingWorkspaceOpen,
useWorkspaceLayoutStoreHydrated,
} from "@/stores/workspace-layout-store";
import {
@@ -271,12 +272,10 @@ interface WorkspaceScreenProps {
serverId: string;
workspaceId: string;
isRouteFocused?: boolean;
recoveryRequested?: boolean;
}
type WorkspaceScreenContentProps = WorkspaceScreenProps & {
isRouteFocused: boolean;
recoveryRequested: boolean;
};
function trimNonEmpty(value: string | null | undefined): string | null {
@@ -419,7 +418,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
serverId,
workspaceId,
isRouteFocused,
recoveryRequested,
}: WorkspaceScreenProps) {
const navigationFocused = useIsFocused();
useEffect(() => {
@@ -433,7 +431,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
serverId={serverId}
workspaceId={workspaceId}
isRouteFocused={isRouteFocused ?? navigationFocused}
recoveryRequested={recoveryRequested ?? false}
/>
);
});
@@ -748,9 +745,9 @@ function useResolvedWorkspaceRouteState(input: {
function shouldInspectWorkspaceRecovery(
hasHydratedWorkspaces: boolean,
workspace: WorkspaceDescriptor | null,
recoveryRequested: boolean,
pendingOpenTarget: WorkspaceTabTarget | null,
): boolean {
return recoveryRequested && hasHydratedWorkspaces && workspace === null;
return pendingOpenTarget?.kind === "agent" && hasHydratedWorkspaces && workspace === null;
}
function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) {
@@ -1015,7 +1012,6 @@ function WorkspaceScreenContent({
serverId,
workspaceId,
isRouteFocused,
recoveryRequested,
}: WorkspaceScreenContentProps) {
const { t } = useTranslation();
const _insets = useSafeAreaInsets();
@@ -1149,13 +1145,18 @@ function WorkspaceScreenContent({
const hasHydratedWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
);
const pendingOpenTarget = usePendingWorkspaceOpen({
workspaceKey: persistenceKey,
hasHydratedWorkspaces,
workspaceExists: workspaceDescriptor !== null,
});
const workspaceRecovery = useWorkspaceRecovery({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
enabled: shouldInspectWorkspaceRecovery(
hasHydratedWorkspaces,
workspaceDescriptor,
recoveryRequested,
pendingOpenTarget,
),
});
@@ -38,6 +38,7 @@ function navigateDeps(): NavigateToWorkspaceDeps {
getSessionAgents: (serverId) =>
useSessionStore.getState().sessions[serverId]?.agents.values() ?? [],
isWorkspaceLayoutHydrated: () => useWorkspaceLayoutStore.persist.hasHydrated(),
deferOpenTab: (input) => useWorkspaceLayoutStore.getState().deferOpenTab(input),
openTab: (input) => useWorkspaceLayoutStore.getState().openTab(input),
rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection),
navigateToRoute: (route) => {
@@ -21,7 +21,12 @@ function createFakeDeps(overrides: Partial<NavigateToWorkspaceDeps> = {}) {
const navigations: string[] = [];
const remembered: ActiveWorkspaceSelection[] = [];
const openedTabs: RecordedTab[] = [];
const deferredTabs: unknown[] = [];
const deps: NavigateToWorkspaceDeps = {
deferOpenTab: (input) => {
deferredTabs.push(input);
return null;
},
getSessionWorkspaces: () => null,
getSessionAgents: () => [] as Agent[],
isWorkspaceLayoutHydrated: () => true,
@@ -33,7 +38,7 @@ function createFakeDeps(overrides: Partial<NavigateToWorkspaceDeps> = {}) {
navigateToRoute: (route) => navigations.push(route),
...overrides,
};
return { deps, navigations, remembered, openedTabs };
return { deps, navigations, remembered, openedTabs, deferredTabs };
}
function createLastSelectionDeps(
@@ -139,7 +144,7 @@ describe("workspace navigation", () => {
});
it("defers an agent tab until a missing workspace is recovered", () => {
const { deps, navigations, openedTabs } = createFakeDeps({
const { deps, navigations, openedTabs, deferredTabs } = createFakeDeps({
getSessionWorkspaces: () => new Map(),
});
@@ -153,7 +158,8 @@ describe("workspace navigation", () => {
);
expect(openedTabs).toEqual([]);
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a?open=agent%3Aagent-1"]);
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a"]);
expect(deferredTabs).toHaveLength(1);
});
it("defers an agent tab until persisted workspace layout has hydrated", () => {
@@ -161,7 +167,7 @@ describe("workspace navigation", () => {
id: "workspace-a",
workspaceDirectory: "/repo/workspace-a",
} as WorkspaceDescriptor;
const { deps, navigations, openedTabs } = createFakeDeps({
const { deps, navigations, openedTabs, deferredTabs } = createFakeDeps({
getSessionWorkspaces: () => new Map([[workspace.id, workspace]]),
isWorkspaceLayoutHydrated: () => false,
});
@@ -177,7 +183,8 @@ describe("workspace navigation", () => {
);
expect(openedTabs).toEqual([]);
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a?open=agent%3Aagent-1"]);
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a"]);
expect(deferredTabs).toHaveLength(1);
});
it("reads the active workspace from the current route", () => {
@@ -1,7 +1,6 @@
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { pickAttentionAgent } from "@/utils/agent-attention";
import {
buildHostWorkspaceOpenRoute,
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
parseHostWorkspaceRouteFromPathname,
@@ -13,6 +12,7 @@ import {
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { prepareWorkspaceTab, type PrepareWorkspaceTabDeps } from "@/utils/prepare-workspace-tab";
import type { OpenWorkspaceTabInput } from "@/stores/workspace-layout-store";
import type { WorkspaceTabPlacement } from "@/stores/workspace-layout-actions";
export interface RouteSelectionInput {
@@ -35,6 +35,7 @@ export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps {
getSessionWorkspaces: (serverId: string) => Map<string, WorkspaceDescriptor> | null | undefined;
getSessionAgents: (serverId: string) => Iterable<Agent>;
isWorkspaceLayoutHydrated: () => boolean;
deferOpenTab: (input: OpenWorkspaceTabInput) => null;
rememberLastWorkspace: (selection: ActiveWorkspaceSelection) => void;
navigateToRoute: (route: string) => void;
}
@@ -91,13 +92,10 @@ export function navigateToWorkspace(
workspaces,
workspaceId: input.workspaceId,
});
const shouldDeferAgentOpen = Boolean(
input.target?.kind === "agent" && (!resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated()),
);
const shouldDeferOpen = !resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated();
if (input.target) {
if (!shouldDeferAgentOpen) {
prepareWorkspaceTab({ ...input, target: input.target }, deps);
}
const openTab = shouldDeferOpen ? deps.deferOpenTab : deps.openTab;
prepareWorkspaceTab({ ...input, target: input.target }, { openTab });
} else {
const workspaceAgents = resolvedWorkspaceId
? Array.from(deps.getSessionAgents(input.serverId)).filter(
@@ -114,14 +112,7 @@ export function navigateToWorkspace(
}
}
const route =
input.target?.kind === "agent" && shouldDeferAgentOpen
? buildHostWorkspaceOpenRoute(
input.serverId,
input.workspaceId,
`agent:${input.target.agentId}`,
)
: buildHostWorkspaceRoute(input.serverId, input.workspaceId);
const route = buildHostWorkspaceRoute(input.serverId, input.workspaceId);
deps.rememberLastWorkspace({ serverId: input.serverId, workspaceId: input.workspaceId });
deps.navigateToRoute(route);
return route;
@@ -5131,3 +5131,43 @@ describe("compact workspace sheet", () => {
expect(received).toEqual([[], ["child"], []]);
});
});
describe("pending workspace opens", () => {
it("retains an open across hydration and consumes it once only for its workspace", async () => {
const values = new Map<string, string>();
const store = createWorkspaceLayoutStore(undefined, {
getItem: async (key) => values.get(key) ?? null,
setItem: async (key, value) => {
values.set(key, value);
},
removeItem: async (key) => {
values.delete(key);
},
});
store.getState().deferOpenTab({
workspaceKey: "server:beta",
target: { kind: "agent", agentId: "b" },
intent: "reveal",
pin: true,
});
await store.persist.rehydrate();
expect(store.getState().consumePendingOpen("server:alpha")).toBeNull();
expect(
store
.getState()
.getWorkspaceTabs("server:alpha")
.filter((tab) => tab.target.kind === "agent"),
).toEqual([]);
expect(store.getState().consumePendingOpen("server:beta")).toBe("agent_b");
expect(store.getState().consumePendingOpen("server:beta")).toBeNull();
expect(
store
.getState()
.getWorkspaceTabs("server:beta")
.filter((tab) => tab.target.kind === "agent")
.map((tab) => tab.target),
).toEqual([{ kind: "agent", agentId: "b" }]);
expect(store.getState().pinnedAgentIdsByWorkspace["server:beta"]).toEqual(new Set(["b"]));
expect(store.getState().pendingOpenByWorkspace).toEqual({});
});
});
@@ -1,5 +1,5 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect, useState } from "react";
import { useEffect, useLayoutEffect, useState } from "react";
import { create } from "zustand";
import { persist, type StateStorage } from "zustand/middleware";
import type { z } from "zod";
@@ -130,6 +130,9 @@ export type WorkspaceTabDestination =
interface WorkspaceLayoutStore {
/** Published once by the app shell; callers never pass a form factor to open a tab. */
layoutMode: WorkspaceLayoutMode;
pendingOpenByWorkspace: Record<string, OpenWorkspaceTabInput>;
deferOpenTab: (input: OpenWorkspaceTabInput) => null;
consumePendingOpen: (workspaceKey: string) => string | null;
/**
* Each workspace's sheet tabs in the order they opened, so the last is the sheet on top. The
* list never reorders: the stack belongs to the sheet primitive, and moving a sheet would remount
@@ -1020,6 +1023,26 @@ export function createWorkspaceLayoutStore(
}
return tabId;
},
pendingOpenByWorkspace: {},
deferOpenTab: (input) => {
set((state) => ({
pendingOpenByWorkspace: {
...state.pendingOpenByWorkspace,
[input.workspaceKey]: input,
},
}));
return null;
},
consumePendingOpen: (workspaceKey) => {
const pending = get().pendingOpenByWorkspace[workspaceKey];
if (!pending) return null;
set((state) => {
const { [workspaceKey]: _pending, ...pendingOpenByWorkspace } =
state.pendingOpenByWorkspace;
return { pendingOpenByWorkspace };
});
return get().openTab(pending);
},
layoutMode: "split-panes",
sheetsByWorkspace: {},
layoutByWorkspace: {},
@@ -2160,6 +2183,7 @@ export function createWorkspaceLayoutStore(
set((state) => {
const hasAny =
normalizedWorkspaceKey in state.pendingOpenByWorkspace ||
normalizedWorkspaceKey in state.layoutByWorkspace ||
normalizedWorkspaceKey in state.splitSizesByWorkspace ||
normalizedWorkspaceKey in state.explorerSidebarWidthByWorkspace ||
@@ -2173,6 +2197,8 @@ export function createWorkspaceLayoutStore(
if (!hasAny) {
return state;
}
const { [normalizedWorkspaceKey]: _pendingOpen, ...pendingOpenByWorkspace } =
state.pendingOpenByWorkspace;
const {
[normalizedWorkspaceKey]: _autoOpened,
...pullRequestTabAutoOpenedByWorkspace
@@ -2199,6 +2225,7 @@ export function createWorkspaceLayoutStore(
state.sidePaneIdByWorkspace;
return {
...withSheets(state, normalizedWorkspaceKey, NO_SHEETS),
pendingOpenByWorkspace,
pullRequestTabAutoOpenedByWorkspace,
layoutByWorkspace,
splitSizesByWorkspace,
@@ -2348,3 +2375,27 @@ export function useWorkspaceLayoutStoreHydrated(): boolean {
return hasHydrated;
}
interface PendingWorkspaceOpenReadiness {
workspaceKey: string | null;
hasHydratedWorkspaces: boolean;
workspaceExists: boolean;
}
export function usePendingWorkspaceOpen({
workspaceKey,
hasHydratedWorkspaces,
workspaceExists,
}: PendingWorkspaceOpenReadiness): WorkspaceTabTarget | null {
const workspaceReady = hasHydratedWorkspaces && workspaceExists;
const pending = useWorkspaceLayoutStore((state) =>
workspaceKey ? state.pendingOpenByWorkspace[workspaceKey] : undefined,
);
const layoutHydrated = useWorkspaceLayoutStoreHydrated();
useLayoutEffect(() => {
if (workspaceKey && workspaceReady && layoutHydrated && pending) {
useWorkspaceLayoutStore.getState().consumePendingOpen(workspaceKey);
}
}, [workspaceKey, workspaceReady, layoutHydrated, pending]);
return pending ? pending.target : null;
}
@@ -62,7 +62,7 @@ describe("resolveNavigateToAgent", () => {
]);
});
it("uses the input workspaceId without reading the nav target", () => {
it("uses the input workspaceId only when the store has no record", () => {
const readTargets: { serverId: string; agentId: string }[] = [];
const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: null });
deps.readAgentNavTarget = (input) => {
@@ -75,7 +75,7 @@ describe("resolveNavigateToAgent", () => {
deps,
);
expect(readTargets).toEqual([]);
expect(readTargets).toEqual([{ serverId: SERVER_ID, agentId: AGENT_ID }]);
expect(tabNavigations).toEqual([
{
serverId: SERVER_ID,
@@ -86,6 +86,12 @@ describe("resolveNavigateToAgent", () => {
]);
});
it("prefers the current agent workspace over a stale input workspace", () => {
const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: "current" });
resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, workspaceId: "stale" }, deps);
expect(tabNavigations[0]?.workspaceId).toBe("current");
});
it("falls back to the host agent route when the agent has no workspaceId", () => {
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
agentWorkspaceId: null,
@@ -26,8 +26,8 @@ export function resolveNavigateToAgent(
deps: NavigateToAgentDeps,
): string {
const agentWorkspaceId =
input.workspaceId ??
deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId }).agentWorkspaceId;
deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId })
.agentWorkspaceId ?? input.workspaceId;
const workspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
if (!workspaceId) {
@@ -1,104 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing";
describe("resolveNotificationTarget", () => {
it("extracts non-empty server and agent ids", () => {
expect(
resolveNotificationTarget({
serverId: " server-123 ",
agentId: " agent-456 ",
}),
).toEqual({
serverId: "server-123",
agentId: "agent-456",
workspaceId: null,
terminalId: null,
});
});
it("returns null for missing/empty ids", () => {
expect(resolveNotificationTarget({ serverId: "", agentId: " " })).toEqual({
serverId: null,
agentId: null,
workspaceId: null,
terminalId: null,
});
expect(resolveNotificationTarget(undefined)).toEqual({
serverId: null,
agentId: null,
workspaceId: null,
terminalId: null,
});
});
it("does not treat cwd as a workspace id alias", () => {
expect(
resolveNotificationTarget({
serverId: "srv-1",
agentId: "agent-1",
cwd: "/tmp/repo",
}),
).toEqual({
serverId: "srv-1",
agentId: "agent-1",
workspaceId: null,
terminalId: null,
});
});
});
describe("buildNotificationRoute", () => {
it("routes to the agent path when workspace id is present", () => {
expect(
buildNotificationRoute({
serverId: "srv-1",
agentId: "agent-1",
workspaceId: "ws-main",
}),
).toBe("/h/srv-1/workspace/ws-main?open=agent%3Aagent-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", () => {
expect(
buildNotificationRoute({
serverId: "srv-1",
workspaceId: "ws-main",
terminalId: "term-1",
}),
).toBe("/h/srv-1/workspace/ws-main?open=terminal%3Aterm-1");
});
it("falls back to host root for a terminal without a workspace id", () => {
expect(
buildNotificationRoute({
serverId: "srv-1",
terminalId: "term-1",
cwd: "/tmp/repo",
}),
).toBe("/h/srv-1");
});
it("falls back to host root when only serverId is present", () => {
expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/h/srv-only");
});
it("falls back to root when no server id is present", () => {
expect(buildNotificationRoute({ agentId: "agent-legacy" })).toBe("/");
expect(buildNotificationRoute(undefined)).toBe("/");
});
it("encodes path segments", () => {
expect(
buildNotificationRoute({
serverId: "srv/with/slash",
workspaceId: "workspace-1",
agentId: "agent with space",
}),
).toBe("/h/srv%2Fwith%2Fslash/workspace/workspace-1?open=agent%3Aagent%20with%20space");
});
});
@@ -1,42 +0,0 @@
import type { Href } from "expo-router";
import { buildHostRootRoute, buildHostWorkspaceOpenRoute } from "@/utils/host-routes";
type NotificationData = Record<string, unknown> | null | undefined;
type NotificationRoute = Extract<Href, string>;
function readNonEmptyString(data: NotificationData, key: string): string | null {
const value = data?.[key];
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function resolveNotificationTarget(data: NotificationData): {
serverId: string | null;
agentId: string | null;
workspaceId: string | null;
terminalId: string | null;
} {
return {
serverId: readNonEmptyString(data, "serverId"),
agentId: readNonEmptyString(data, "agentId"),
workspaceId: readNonEmptyString(data, "workspaceId"),
terminalId: readNonEmptyString(data, "terminalId"),
};
}
export function buildNotificationRoute(data: NotificationData): NotificationRoute {
const { serverId, agentId, workspaceId, terminalId } = resolveNotificationTarget(data);
if (serverId && workspaceId && agentId) {
return buildHostWorkspaceOpenRoute(serverId, workspaceId, `agent:${agentId}`);
}
if (serverId && workspaceId && terminalId) {
return buildHostWorkspaceOpenRoute(serverId, workspaceId, `terminal:${terminalId}`);
}
if (serverId) {
return buildHostRootRoute(serverId);
}
return "/" as const;
}
@@ -1,3 +1,4 @@
import { WEB_NOTIFICATION_CLICK_EVENT } from "@/notification-open";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
interface MockNotificationOptions {
@@ -132,7 +133,7 @@ describe("sendOsNotification", () => {
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = dispatchEvent;
(globalThis as { location?: unknown }).location = { assign };
const { sendOsNotification, WEB_NOTIFICATION_CLICK_EVENT } = await loadModuleForPlatform("web");
const { sendOsNotification } = await loadModuleForPlatform("web");
const sent = await sendOsNotification({
title: "Agent finished",
@@ -159,7 +160,7 @@ describe("sendOsNotification", () => {
expect(assign).not.toHaveBeenCalled();
});
it("falls back to route navigation when no listener handles the click", async () => {
it("leaves navigation to the app when no listener handles the click", async () => {
const created: MockNotificationInstance[] = [];
class MockNotification implements MockNotificationInstance {
@@ -207,9 +208,7 @@ describe("sendOsNotification", () => {
expect(clicked.clickListeners).toHaveLength(1);
clicked.clickListeners[0]?.({} as Event);
expect(assign).toHaveBeenCalledWith(
"/h/srv%20with%20space/workspace/workspace-1?open=agent%3Aagent%2F1",
);
expect(assign).not.toHaveBeenCalled();
});
it("returns false when the Notification API is unavailable", async () => {
+3 -25
View File
@@ -1,6 +1,6 @@
import { Asset } from "expo-asset";
import { getDesktopHost } from "@/desktop/host";
import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing";
import { parseNotificationOpenTarget, WEB_NOTIFICATION_CLICK_EVENT } from "@/notification-open";
import { isNative } from "@/constants/platform";
interface OsNotificationPayload {
@@ -17,8 +17,6 @@ interface WebNotificationInstance {
addEventListener: (type: "click", listener: (event: Event) => void) => void;
}
export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
let permissionRequest: Promise<boolean> | null = null;
let notificationIconUrl: string | null | undefined;
@@ -98,8 +96,7 @@ export async function ensureOsNotificationPermission(): Promise<boolean> {
}
function hasNotificationClickTarget(data: Record<string, unknown> | undefined): boolean {
const target = resolveNotificationTarget(data);
return target.serverId !== null || target.agentId !== null || target.workspaceId !== null;
return parseNotificationOpenTarget(data) !== null;
}
function getWebNotificationIconUrl(): string | undefined {
@@ -135,31 +132,12 @@ function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boole
return !dispatch(event);
}
function fallbackNavigateToNotificationTarget(data: Record<string, unknown> | undefined): void {
const route = buildNotificationRoute(data);
const location = (globalThis as { location?: { assign?: (url: string) => void; href?: string } })
.location;
if (!location) {
return;
}
if (typeof location.assign === "function") {
location.assign(route);
return;
}
if (typeof location.href === "string") {
location.href = route;
}
}
function attachWebClickHandler(
notification: WebNotificationInstance,
data: Record<string, unknown> | undefined,
): void {
notification.addEventListener("click", () => {
const handledByApp = dispatchWebNotificationClick({ data });
if (!handledByApp) {
fallbackNavigateToNotificationTarget(data);
}
dispatchWebNotificationClick({ data });
});
}
+2 -2
View File
@@ -188,6 +188,7 @@ test("PR routing declares stable behavior ownership", () => {
desktop: [
"packages/desktop/**",
"packages/app/src/desktop/**",
"packages/app/src/**/*.electron.{ts,tsx}",
"packages/server/src/server/browser-tools/**",
"packages/app/e2e/support/**",
"packages/app/*config.{cjs,js,ts}",
@@ -251,13 +252,11 @@ test("browser and desktop tests have exclusive, directory-owned suites", () => {
const filters = loadFilters(filtersPath);
const browserSpecs = filesUnder("packages/app/e2e", (path) => path.endsWith(".spec.ts"));
const desktopSpecs = filesUnder("packages/desktop/e2e", (path) => path.endsWith(".spec.ts"));
const electronModules = filesUnder("packages/app/src", (path) => /\.electron\.tsx?$/.test(path));
assert.ok(browserSpecs.length > 0);
assert.ok(desktopSpecs.length > 0);
assert.ok(browserSpecs.every((path) => path.startsWith("packages/app/e2e/browser/")));
assert.ok(desktopSpecs.every((path) => path.startsWith("packages/desktop/e2e/")));
assert.ok(electronModules.every((path) => path.startsWith("packages/app/src/desktop/")));
const desktopPackage = JSON.parse(readFileSync(desktopPackagePath, "utf8"));
assert.match(desktopPackage.scripts.test, /--exclude ["']e2e\/\*\*["']/);
@@ -277,6 +276,7 @@ test("browser and desktop tests have exclusive, directory-owned suites", () => {
assert.deepEqual(filters.desktop, [
"packages/desktop/**",
"packages/app/src/desktop/**",
"packages/app/src/**/*.electron.{ts,tsx}",
"packages/server/src/server/browser-tools/**",
"packages/app/e2e/support/**",
"packages/app/*config.{cjs,js,ts}",