Skip to content

Fix Android notification deeplinks to target agent chat#1450

Open
bjspi wants to merge 1 commit into
getpaseo:mainfrom
bjspi:fix/android-notification-deeplinks
Open

Fix Android notification deeplinks to target agent chat#1450
bjspi wants to merge 1 commit into
getpaseo:mainfrom
bjspi:fix/android-notification-deeplinks

Conversation

@bjspi

@bjspi bjspi commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Fix Android notification taps so they reliably land in the intended agent chat instead of falling back to the host start page or replaying an older chat target.

  • keep /h/:serverId/agent/:agentId deeplinks alive while the host connection is still coming online
  • clear handled Expo notification responses so stale Android launches do not replay an already-consumed notification target
  • add focused unit coverage for the agent-route fallback decision

Root Cause Analysis

There are two related failure modes in the native notification launch path.

1. Cold-start notification taps could be abandoned before the host socket came online

PushNotificationRouter reads the notification response and calls navigateToAgent({ serverId, agentId, ... }). On a cold Android start, session/workspace state often is not hydrated yet, so navigateToAgent falls back to the intermediate route /h/:serverId/agent/:agentId.

That route previously treated !client || !isConnected as a terminal failure. On its first effect pass it immediately did router.replace(buildHostRootRoute(serverId)) and set redirectedRef.current = true. If Android launched from a notification before the host runtime reached online, the original agent-chat intent was discarded and never retried. The visible result was a notification tap landing on the host start page instead of the target chat.

2. Handled notification responses could be replayed on later native mounts

The native notification effect read Notifications.getLastNotificationResponseAsync() on mount and routed from it, but never cleared the response after handling it. Expo documents clearLastNotificationResponse() for the exact case where an app selects a route from a notification response and should not continue selecting that route after it has already been handled:

https://docs.expo.dev/versions/v54.0.0/sdk/notifications/#notificationsclearlastnotificationresponse

On Android, this can present as a later launch replaying an older notification response, sending the user to the wrong previous chat instead of their current/expected position.

Fix Details

  • HostAgentReadyRoute now tracks a short connection grace period before falling back to host root.
  • The fallback decision is extracted to shouldFallbackHostAgentReadyRoute() and covered with focused tests.
  • When the route has a known agentCwd but workspace hydration is still pending, it continues to wait instead of abandoning the deeplink.
  • Once the host is connected, the existing fetchAgent() resolution path can run and route to the prepared workspace tab for the target agent.
  • The native notification handler now uses Expo's synchronous getLastNotificationResponse() API and calls clearLastNotificationResponse() immediately after consuming a response.

Testing

  • npm ci
  • npm run build:app-deps
  • npm run test --workspace=@getpaseo/app -- agent-ready-route-state.test.ts
  • npm run typecheck --workspace=@getpaseo/app
  • npm run lint -- "packages/app/src/app/_layout.tsx" "packages/app/src/app/h/[serverId]/agent/[agentId].tsx" "packages/app/src/app/h/[serverId]/agent/agent-ready-route-state.ts" "packages/app/src/app/h/[serverId]/agent/agent-ready-route-state.test.ts"
  • npm run format:check:files -- "packages/app/src/app/_layout.tsx" "packages/app/src/app/h/[serverId]/agent/[agentId].tsx" "packages/app/src/app/h/[serverId]/agent/agent-ready-route-state.ts" "packages/app/src/app/h/[serverId]/agent/agent-ready-route-state.test.ts"

Manual Android Repro To Validate

  1. Force-stop the Android app.
  2. Trigger a push notification for a specific agent while the host connection will take a moment to come online.
  3. Tap the notification.
  4. Expected: the app waits for host/session resolution and opens the workspace with that exact agent chat focused, rather than landing on host root.
  5. After handling a notification, relaunch the app normally from the launcher.
  6. Expected: the old notification target is not replayed into a previous chat.

Keep agent notification deeplinks alive while the host connection is still coming online, and clear handled Expo notification responses so stale Android launches do not replay an older chat target.
@greptile-apps

greptile-apps Bot commented Jun 10, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes two Android notification deeplink failure modes: a cold-start race where the agent route redirected to host root before the host socket finished connecting, and a stale-response replay where a previous notification target could reopen on a later launch. Both issues are now addressed in their respective layers.

  • PushNotificationRouter in _layout.tsx switches to the synchronous getLastNotificationResponse() API (the async form is now deprecated) and calls clearLastNotificationResponse() after consuming any response, preventing stale replays on subsequent cold starts.
  • HostAgentReadyRoute in [agentId].tsx gains a 5-second connection grace period before falling back to the host root page; the fallback predicate is extracted to shouldFallbackHostAgentReadyRoute in a colocated module with focused unit tests.

Confidence Score: 4/5

The change is safe to merge; both fixes address well-diagnosed bugs with targeted, isolated changes and the notification API migration is straightforward.

The core logic in shouldFallbackHostAgentReadyRoute and the timer management in [agentId].tsx are correct, and the _layout.tsx change is a clean migration to the synchronous Expo API. A redundant reset effect adds minor noise, and the test suite is missing one counterpart case (hydrated workspaces + timed-out connection = fallback should trigger) that would guard the most interesting path introduced by this PR.

[agentId].tsx is worth a second look for the redundant reset effect and the multi-effect interaction; agent-ready-route-state.test.ts is missing the fallback-does-trigger counterpart for the hydrated-workspace scenario.

Important Files Changed

Filename Overview
packages/app/src/app/_layout.tsx Switches cold-start notification handling from async to synchronous getLastNotificationResponse() and adds clearLastNotificationResponse() after consuming a response; change is clean and well-scoped.
packages/app/src/app/h/[serverId]/agent/[agentId].tsx Adds connection-grace-period logic with a timer effect and a state variable; contains a redundant reset effect that adds noise without contributing behavior.
packages/app/src/app/h/[serverId]/agent/agent-ready-route-state.ts New module extracting the fallback-decision predicate into a testable pure function; logic is correct and the extraction is justified by the focused unit tests.
packages/app/src/app/h/[serverId]/agent/agent-ready-route-state.test.ts Four targeted tests cover the main branches; missing a test for the case where agentCwd is known, workspaces have hydrated, the connection has timed out, and fallback should trigger.

Sequence Diagram

sequenceDiagram
    participant OS as Android OS
    participant Layout as PushNotificationRouter
    participant Route as HostAgentReadyRoute
    participant Store as SessionStore
    participant Runtime as HostRuntime

    OS->>Layout: Cold-start (notification tap)
    Layout->>Layout: getLastNotificationResponse() [sync]
    Layout->>Layout: openFromResponse(response)
    Layout->>Layout: clearLastNotificationResponse()
    Layout->>Route: navigate /h/:serverId/agent/:agentId

    Route->>Runtime: useHostRuntimeConnectionStatus(serverId)
    Runtime-->>Route: connecting (not yet online)
    Route->>Route: startTimer(5000ms)

    alt Connection comes online within 5s
        Runtime-->>Route: "status = online"
        Route->>Route: clearTimeout, setConnectionFallbackReady(false)
        Route->>Runtime: fetchAgent(agentId)
        Runtime-->>Route: agent.cwd
        Store-->>Route: resolvedWorkspaceId
        Route->>Route: navigateToPreparedWorkspaceTab
    else Timer fires AND agentCwd known AND workspaces pending
        Route->>Route: shouldFallbackHostAgentReadyRoute → false
        Store-->>Route: "hasHydratedWorkspaces = true"
        Route->>Route: shouldFallbackHostAgentReadyRoute → true
        Route->>Route: router.replace(hostRootRoute)
    else Timer fires AND no agentCwd
        Route->>Route: shouldFallbackHostAgentReadyRoute → true
        Route->>Route: router.replace(hostRootRoute)
    end
Loading

Reviews (1): Last reviewed commit: "Fix Android notification deeplink routin..." | Re-trigger Greptile

Comment on lines +48 to +52
useEffect(() => {
setConnectionFallbackReady(false);
}, [agentId, serverId]);

useEffect(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Redundant reset effect — Effect 2 (lines 52-68) unconditionally calls setConnectionFallbackReady(false) as its very first operation before starting the timer, so this separate effect adds no behavior. The only gap where it could matter is when Effect 2 exits early via !agentId || !serverId || redirectedRef.current, but those conditions imply the component is either parameterless or already navigated, making the fallback state irrelevant in either case.

Suggested change
useEffect(() => {
setConnectionFallbackReady(false);
}, [agentId, serverId]);
useEffect(() => {
useEffect(() => {

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +28 to +37
});

it("does not fall back while a known agent cwd waits for workspace hydration", () => {
expect(
shouldFallbackHostAgentReadyRoute({
agentCwd: "/repo/project",
hasHydratedWorkspaces: false,
hasClient: false,
isConnected: false,
connectionFallbackReady: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing coverage for the "fallback triggers after workspace hydrates" path

Test 3 verifies that a known agentCwd with hasHydratedWorkspaces: false suppresses fallback. The symmetric case — agentCwd set, hasHydratedWorkspaces: true, hasClient: false, isConnected: false, connectionFallbackReady: true — is untested. This is exactly the scenario the PR calls out: workspace data has arrived but the host connection timed out. Without a test here, accidentally widening the first guard (e.g. removing the !input.hasHydratedWorkspaces check) would silently flip the behavior of this path.

sakurayun pushed a commit to sakurayun/paseo-reclaude that referenced this pull request Jun 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant