diff --git a/.github/ci-paths.yml b/.github/ci-paths.yml index 833820ff2d..87fc924b6b 100644 --- a/.github/ci-paths.yml +++ b/.github/ci-paths.yml @@ -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}" diff --git a/CLAUDE.md b/CLAUDE.md index 841a4c477e..79bdef92f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | diff --git a/docs/expo-router.md b/docs/expo-router.md index cc65eb57a7..3bd6a43435 100644 --- a/docs/expo-router.md +++ b/docs/expo-router.md @@ -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 diff --git a/docs/notifications.md b/docs/notifications.md new file mode 100644 index 0000000000..0e58ccafae --- /dev/null +++ b/docs/notifications.md @@ -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). diff --git a/packages/app/e2e/browser/workspace-navigation-regression.spec.ts b/packages/app/e2e/browser/workspace-navigation-regression.spec.ts index fdc0529451..7d8b484c77 100644 --- a/packages/app/e2e/browser/workspace-navigation-regression.spec.ts +++ b/packages/app/e2e/browser/workspace-navigation-regression.spec.ts @@ -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 { await closeButton.first().click(); } +async function openWorkspaceTabUrl(page: Page, route: string): Promise { + 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); diff --git a/packages/app/e2e/support/helpers/terminal-perf.ts b/packages/app/e2e/support/helpers/terminal-perf.ts index ac41b7343b..5c00bed485 100644 --- a/packages/app/e2e/support/helpers/terminal-perf.ts +++ b/packages/app/e2e/support/helpers/terminal-perf.ts @@ -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) diff --git a/packages/app/e2e/support/helpers/workspace-layout.ts b/packages/app/e2e/support/helpers/workspace-layout.ts index 8fbc893f5f..80c2536fbf 100644 --- a/packages/app/e2e/support/helpers/workspace-layout.ts +++ b/packages/app/e2e/support/helpers/workspace-layout.ts @@ -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 { + 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 }); +} diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 559726d7c8..f24df99f26 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -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({ startupBlocker: { kind: "none" }, }); -function PushNotificationRouter() { - const router = useRouter(); - const lastHandledIdRef = useRef(null); - const openNotification = useStableEvent((data: Record | 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 }).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; - 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 - | 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 ( @@ -959,10 +842,15 @@ function AppShell() { ); } +function NotificationOpenBridge() { + const { startupBlocker } = useHostRuntimeBootstrapState(); + return ; +} + function RuntimeProviders({ children }: { children: ReactNode }) { return ( - + {children} diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 7f66a07b25..008999a540 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -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)); diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx index 80ba6df4d4..8f80f185c7 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx @@ -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(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 ; + return ; } -function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) { +function WorkspaceDeck() { const activeSelection = useActiveWorkspaceSelection(); const [retainedSelections, setRetainedSelections] = useState(() => 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} /> ); diff --git a/packages/app/src/fdroid/expo-notifications.ts b/packages/app/src/fdroid/expo-notifications.ts index dfd4908c2c..3a8e30fc96 100644 --- a/packages/app/src/fdroid/expo-notifications.ts +++ b/packages/app/src/fdroid/expo-notifications.ts @@ -38,3 +38,5 @@ export function addNotificationResponseReceivedListener() { export async function getLastNotificationResponseAsync() { return null; } + +export function clearLastNotificationResponse(): void {} diff --git a/packages/app/src/navigation/agent-route-resolution.test.ts b/packages/app/src/navigation/agent-route-resolution.test.ts index 99fb95ec45..b87b375aed 100644 --- a/packages/app/src/navigation/agent-route-resolution.test.ts +++ b/packages/app/src/navigation/agent-route-resolution.test.ts @@ -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({ diff --git a/packages/app/src/navigation/agent-route-resolution.ts b/packages/app/src/navigation/agent-route-resolution.ts index 88fb848c5b..0e5f9f6e11 100644 --- a/packages/app/src/navigation/agent-route-resolution.ts +++ b/packages/app/src/navigation/agent-route-resolution.ts @@ -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 }; } diff --git a/packages/app/src/notification-open/index.ts b/packages/app/src/notification-open/index.ts new file mode 100644 index 0000000000..cd5a9c93bb --- /dev/null +++ b/packages/app/src/notification-open/index.ts @@ -0,0 +1,6 @@ +export { + createNotificationOpenRouter, + parseNotificationOpenTarget, + WEB_NOTIFICATION_CLICK_EVENT, +} from "./model"; +export type { NotificationOpenSource, NotificationTap } from "./source"; diff --git a/packages/app/src/notification-open/model.test.ts b/packages/app/src/notification-open/model.test.ts new file mode 100644 index 0000000000..ffcb516b0d --- /dev/null +++ b/packages/app/src/notification-open/model.test.ts @@ -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((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(); + }); +}); diff --git a/packages/app/src/notification-open/model.ts b/packages/app/src/notification-open/model.ts new file mode 100644 index 0000000000..79c40394bb --- /dev/null +++ b/packages/app/src/notification-open/model.ts @@ -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) => void; + whenReadyToNavigate: () => Promise; +} + +export function createNotificationOpenRouter(deps: NotificationOpenRouterDeps) { + const handledIds = new Set(); + 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; + }, + }; +} diff --git a/packages/app/src/notification-open/react/index.tsx b/packages/app/src/notification-open/react/index.tsx new file mode 100644 index 0000000000..87507a36cf --- /dev/null +++ b/packages/app/src/notification-open/react/index.tsx @@ -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((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; +} diff --git a/packages/app/src/notification-open/source.electron.ts b/packages/app/src/notification-open/source.electron.ts new file mode 100644 index 0000000000..a6fd0a61fd --- /dev/null +++ b/packages/app/src/notification-open/source.electron.ts @@ -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?.(); + }; + }, + }; +} diff --git a/packages/app/src/notification-open/source.native.ts b/packages/app/src/notification-open/source.native.ts new file mode 100644 index 0000000000..0700d58c81 --- /dev/null +++ b/packages/app/src/notification-open/source.native.ts @@ -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(); + }, + }; +} diff --git a/packages/app/src/notification-open/source.ts b/packages/app/src/notification-open/source.ts new file mode 100644 index 0000000000..b88b1880e0 --- /dev/null +++ b/packages/app/src/notification-open/source.ts @@ -0,0 +1,12 @@ +export interface NotificationTap { + id: string; + data: Record | undefined; +} + +export interface NotificationOpenSource { + subscribe(onTap: (tap: NotificationTap) => void): () => void; + takeLaunchTap(): Promise; + acknowledge(tap: NotificationTap): void; +} + +export { createNotificationOpenSource } from "./source.native"; diff --git a/packages/app/src/notification-open/source.web.ts b/packages/app/src/notification-open/source.web.ts new file mode 100644 index 0000000000..31b9aef065 --- /dev/null +++ b/packages/app/src/notification-open/source.web.ts @@ -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() {}, + }; +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 530c1a07d0..df9646f93d 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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, ), }); diff --git a/packages/app/src/stores/navigation-active-workspace-store/index.ts b/packages/app/src/stores/navigation-active-workspace-store/index.ts index be001c5f2d..0404ea34ed 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/index.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/index.ts @@ -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) => { diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts index 3902ba116c..055049baee 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts @@ -21,7 +21,12 @@ function createFakeDeps(overrides: Partial = {}) { 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 = {}) { 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", () => { diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts index f8bb8ccf9f..f234b1c2a6 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts @@ -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 | null | undefined; getSessionAgents: (serverId: string) => Iterable; 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; diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index a989dc0dd3..cfac2f999a 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -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(); + 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({}); + }); +}); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 01c932605e..4091b7d53c 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -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; + 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; +} diff --git a/packages/app/src/utils/navigate-to-agent/resolve.test.ts b/packages/app/src/utils/navigate-to-agent/resolve.test.ts index a9c6bc46c9..f768edfce5 100644 --- a/packages/app/src/utils/navigate-to-agent/resolve.test.ts +++ b/packages/app/src/utils/navigate-to-agent/resolve.test.ts @@ -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, diff --git a/packages/app/src/utils/navigate-to-agent/resolve.ts b/packages/app/src/utils/navigate-to-agent/resolve.ts index a6ba8c155d..842760d793 100644 --- a/packages/app/src/utils/navigate-to-agent/resolve.ts +++ b/packages/app/src/utils/navigate-to-agent/resolve.ts @@ -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) { diff --git a/packages/app/src/utils/notification-routing.test.ts b/packages/app/src/utils/notification-routing.test.ts deleted file mode 100644 index 92fdad43f4..0000000000 --- a/packages/app/src/utils/notification-routing.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/packages/app/src/utils/notification-routing.ts b/packages/app/src/utils/notification-routing.ts deleted file mode 100644 index 04c78f591a..0000000000 --- a/packages/app/src/utils/notification-routing.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Href } from "expo-router"; -import { buildHostRootRoute, buildHostWorkspaceOpenRoute } from "@/utils/host-routes"; - -type NotificationData = Record | null | undefined; -type NotificationRoute = Extract; - -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; -} diff --git a/packages/app/src/utils/os-notifications.test.ts b/packages/app/src/utils/os-notifications.test.ts index 84a8a65bff..234e5f0eb4 100644 --- a/packages/app/src/utils/os-notifications.test.ts +++ b/packages/app/src/utils/os-notifications.test.ts @@ -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 () => { diff --git a/packages/app/src/utils/os-notifications.ts b/packages/app/src/utils/os-notifications.ts index 3d5823b906..c0c73d333d 100644 --- a/packages/app/src/utils/os-notifications.ts +++ b/packages/app/src/utils/os-notifications.ts @@ -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 | null = null; let notificationIconUrl: string | null | undefined; @@ -98,8 +96,7 @@ export async function ensureOsNotificationPermission(): Promise { } function hasNotificationClickTarget(data: Record | 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 | 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 | undefined, ): void { notification.addEventListener("click", () => { - const handledByApp = dispatchWebNotificationClick({ data }); - if (!handledByApp) { - fallbackNavigateToNotificationTarget(data); - } + dispatchWebNotificationClick({ data }); }); } diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 0ec90d2f8b..7401206108 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -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}",