From 3d8907178e631ead48cc327a3d742c0aa875ebad Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:40:54 -0400 Subject: [PATCH] feat(notifications): open a server agent's notification on its server (#26518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(e2e): add a paired phone client helper and phone-mirror baseline specs Later phone-mirror slices need one oracle that drives a real mobile-scoped device with the phone's own capabilities against a serve host or a paired desktop. Pins #26022 across a serve restart and today's gap: a desktop's phone does not list the desktop's server workspaces. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): show SSH host health on the workspace row host badge Rows on an SSH host the desktop cannot reach looked healthy on the phone. The badge now adds the desktop sidebar's own word ("devbox · Disconnected"), derived by the same shared function from the connection status ssh.listTargetSummaries already sends; old desktops keep today's badge. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(e2e): compose the phone-mirror topology and check the cold snapshot Slices need one composer for host, desktop and phone with a single teardown, so specs stop copying setup. The serve-restart spec now reads the first tab-stream snapshot after the cold start, which is what the phone builds its tab strip from. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): name an unhealthy SSH host even in a single-host list The desktop card shows SSH status on any host count, so the phone now reads target summaries whenever a repo is on SSH and badges an unhealthy host alone too. Labels and health share one target-to-host keying, and a hook-level test pins the reply-to-row wiring. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(e2e): keep phone-mirror teardown errors from masking test failures Teardown failures become a test attachment instead of replacing the real failure, and the cold-snapshot poll reports stream errors together with the PTY list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): clear stale SSH host badges and announce them without the dot A catalog that no longer spans hosts or holds an SSH repo now clears the health and label maps, so a host that left cannot keep badging rows. The badge gets a screen-reader label ("devbox, Disconnected") instead of reading the middle dot. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(runtime): let a paired desktop register its phones as delegated mobile devices A desktop paired to this runtime can now call pairing.delegatedMobileDevice.sync to make each of its phones an ordinary mobile-scope device it owns, so the phone mirrors that host through the desktop. Children are never pending, inherit the parent's reach, and die with the parent's grant. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(runtime): move the mobile device revoke primitive below dispatch Dispatch binds the caller-bound delegated-device context, so the revoke path it needs now lives in a layer under it as a pure move; this drops the abstract method seam. No behaviour change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(runtime): keep a runtime grant until its delegated phones are revoked A failed child cleanup now leaves the parent in place so a retry can still find the children, and revokeRuntimeAccess lives beside the revoke it cascades to. Adds restart, rename and bounds coverage, shares credential minting, and drops the timing-dependent census. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(runtime): fail a delegated sync whose dropped phone cannot be revoked Matches the parent revoke: a child whose cleanup could not be saved is kept and the sync errors before minting anything, so the desktop retries instead of believing the server matches its list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): relay a paired phone's server-workspace requests through the desktop A phone mirrors its desktop, including workspaces on the desktop's configured servers. The desktop now forwards a request that names `executionHost: runtime:` to that server over one socket per (phone, server), signed in as the phone's delegated device; replies pass through untouched except stream ids. Hidden until the phone client ships: the relay capability is not advertised yet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the single-host health fixtures inline so the test tsc passes Fixtures hoisted into consts widened their health maps to string, which the now-required hostHealthById rejects under the tests-typecheck ratchet; passing them inline keeps the union. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): state the desktop relay contract in its entry module main dropped docs/reference, so the relay's contract lives beside the code. Also pins that a server whose delegated sync fails (e.g. delegated_device_revoke_failed) is unavailable until a later request syncs it again. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the relay's configured-server adapter The production hosts adapter was only typechecked; this pins that it resolves configured servers by exact id only (never a name or a removed server), fences by pairing revision and runtime identity, and stops reporting retirements once unsubscribed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the repo-metadata goldens for SSH host health The settled metadata state now carries hostHealthById, and an SSH-only catalog now reads the target summaries so a lone unhealthy host can be badged. Scoped re-record via rpc-recording.mts. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): tighten the desktop relay after elegance review Reuses the shared E2EE socket opener, folds relay state into fewer fields, never sends a request whose link ended while opening, passes failure codes through, relays only this desktop's own phones, and drops the unused host-state reader until S2 needs one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): bound relay frames and stop sticky relay verdicts Restores the 8 MiB frame cap on every remote runtime socket, re-checks an update-needed server on each new link so an in-place upgrade relays, answers a phone the server just declined without syncing again, and ends a link whose mirrored capability update cannot be queued. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): run targeted desktop-owned calls on the desktop The phone tags every call on a workspace screen with its execution host, so a paired-desktop method carrying `runtime:` now runs locally instead of failing 'forbidden'. The Mac stays the only reader of the routing table. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): list the desktop's configured servers and their last workspace rows for the phone The phone needs the same host list the desktop shows, including servers that are offline or too old to relay to. mobileRelay.hosts.list/worktrees answer from the desktop's own status and its own worktree.ps fetches, cached per server and fenced by pairing, never from relayed replies. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): list server rows through one desktop fetch, with no side effect on listing The phone now always lists a server's rows through mobileRelay.hosts.worktrees, so the desktop's own fetch is both the live source and the cache, and stamping stays in main. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): fetch a server's full workspace list within a bound, and forget removed servers The desktop now asks for the phone's full-catalog limit, answers within 5s with the last rows when a server hangs, drops a server's rows when it is removed or re-paired, and checks the server's identity after the fetch so a reply from before a re-pair is never served. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): name a server workspace's host on the phone's calls A phone reaches a server's workspace through its desktop only if each call names the host; the direct, cloud-relay and shell-bridge transports now carry `executionHost`, streams keep it for replay and unsubscribe, and an older shell (no `executionHost` grant) refuses rather than strips it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): list managed servers as the desktop sidebar does, and hide rows mid-delete A server Orca manages on an SSH host now lists once under that host's name, or not at all while the host is reached over the SSH relay, using the sidebar's own folding. Rows the server is deleting are left out at reply time, as a phone asking the server itself would see. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a disconnected server's last rows, and never join a re-paired server's fetch Retirement also fires on a manual disconnect or managed-server update, which wrongly emptied the phone's list; rows are now pruned only once a server is no longer configured. Fetches are shared per server identity, so a poll after a re-pair asks the new server right away. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): list the paired desktop's server workspaces on the phone Fixes the phone missing a desktop's `orca serve` workspaces: rows come from the desktop's mobileRelay.hosts.* with each server's health ("Update needed", "Disconnected"), open through the relay, follow the desktop's hidden hosts, and the desktop now advertises the relay capability. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin that a desktop failing the hosts list leaves only its own rows The page asks a relaying desktop for its servers; a refusal such as method_not_found must leave today's list with no server rows and no catalog error. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): mirror the desktop's shown hosts exactly, one host included Jinwoo's call: the phone follows visibleWorkspaceHostIds as the desktop sidebar does, so a one-host list narrows the phone too instead of being read as a single-host focus. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): scope a server workspace at its route, never the desktop Every screen under a server workspace's route now reads one context and gets that server's client and a router that keeps pushes on it, refusing to fall back to the desktop; pins write to the row's owner like the Mac. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): never open an unreachable server workspace, and prove the relay in e2e A server workspace the phone can't reach now gets one explicit screen at its route and no row tap or action-sheet entry opens it; native routes read the route's host, a census pins every workspace route, and the e2e reads files and git on the server. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): gate a server workspace on that server's own features A targeted status.get now relays to the server, so a server workspace's route provides that server's status to every screen under it and its action sheet, while whether the phone can reach the server stays the desktop's answer. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(ci): run the remote-runtime WebSocket opener test on Node Under the bun vitest project, `ws` resolves to Bun's built-in client, which ignores maxPayload, so the frame-cap test hung. The opener only ships on Node/Electron, so run its test in the Node runtime project like its siblings. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(push): carry a notification's workspace server to the phone A desktop relays alerts for agents on its paired servers, but the push named only the server's worktree, so a tap opened it on the desktop and bounced. The gateway accepts an optional runtime execution host and forwards it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(notifications): tell the phone which server runs a notified workspace A server agent's alert reached the phone with the server's worktree id and no host, so tapping it opened the desktop's workspace list and bounced. The producers now name the runtime host, and main carries it into the push. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): open a notified server workspace on its server A push that names a runtime host now routes its tap through that server, like any other server workspace. Absent or non-runtime hosts open as before. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep viewing suppression to the viewed workspace's own host A desktop workspace and a server workspace can share an id, so viewing one must not silence the other's alerts. Also pins a runtime-stamped tab owner. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name the push provider payload builder for its role The anti-slop gate rejects "shape" as a symbol name; the loop variable builds an APNs or FCM payload, so it is named for that. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give the file preview the workspace router, back to 128 page assets The preview was the one workspace screen on the plain router, so the scoped router was reached by a route set of its own and esbuild split it into an extra chunk (129 assets); every workspace screen now uses it and it shares the route's chunk. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-relay): derive the phone allowlist from the routing table Adding a phone method now takes one edit (its route). A server-targeted call on a legacy non-E2EE socket is refused instead of running locally, and only execution-host methods enter the relay. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-relay): drop unread freshness fields from server workspace lists The phone reads only the rows, so stale, fetchedAt, totalCount and truncated were dead wire weight. The desktop still serves a server's last rows while the server is unreachable. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): read only the rows of a server's workspace list The desktop no longer sends a freshness flag, so the phone's schema drops it. The relay capability list names the delegated-devices constant instead of repeating its literal, which also retires a now-circular type check. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): show a server workspace as disconnected while its server is down The phone read the desktop's connection state for a server workspace, so a stopped server kept a green dot and "Loading tabs" spun forever. The workspace route now narrows that state by the server's health from mobileRelay.hosts.list, reusing the existing disconnected session UI and refetching tabs when the server returns. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): share one desktop server-list feed between the host list and workspaces A server workspace polled mobileRelay.hosts.list beside the host list's own read, so the phone could ask the desktop twice per tick and the row badge and the screen could disagree. One feed per desktop client now owns that read; both readers join it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(e2e): read a server's reachability from the hosts list, not a dropped stale flag eff410fe303 dropped `stale` from mobileRelay.hosts.worktrees, so the mirror spec's schema never parsed and its first poll timed out. It now waits on the server's relay/health from mobileRelay.hosts.list, the field the phone itself reads. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * build(mobile): re-measure the page's script sweep for server workspaces Main already sat on its 78-script ceiling (re-swept: 78, table: 74); the workspace route's shared modules split importer sets three ways more, reaching 81. Re-swept per route prefix on this head, as #24257 did for its feature; the spread is now 1 to 11. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): export the server-list feed's React reader beside the feed useRelayHosts moves from workspace-server.ts into relay-hosts-feed.ts, so every reader (workspace screens now, the new-workspace sheet next) subscribes through the feed's own module. No behaviour change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep polling the server list and read a server's reachability from relay A hosts-list read slower than the poll stopped the feed for good, since the next tick was scheduled when the read started; it is now scheduled from the finish. A server workspace now reads connected only when the desktop relays to it, so a server with no status yet is not shown up. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): show a reached server as connecting and a too-old one as disconnected A server the desktop has reached but whose status is not answered yet is moments from relaying, so it reads connecting; a server too old to relay to never will, so it reads disconnected. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(shared): move the account capabilities into their own group module Main's merge put protocol-version.ts at 301 lines, over the 300 cap. The account group moves out like the relay and skill groups did, same strings in the same order; the page's script sweep is re-measured because the split changed its chunking (81 to 79 at 16 routes). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): name a server workspace through the request envelope The server name now rides the generic request envelope from #27815 instead of its own executionHost option, bridge grant and per-transport plumbing. Server rows show only where the transport carries an envelope (native, or a shell that grants rpcEnvelope), so an older app keeps today's list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the server-call wire and the no-envelope deep link A server workspace call still reaches the desktop as the same top-level executionHost frame, on the direct and relay wires, and a deep link into a server workspace under an app without rpcEnvelope shows the unreachable screen. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): read a relay stream's envelope slot in sorted key order Equal envelopes name one cleanup slot whatever their key order, the scoped view passes its client's envelope answer through as it does the generation, and the direct wire harness is shared instead of rebuilt. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): a phone's new chat in a server workspace follows the desktop's chat setting A relayed agent.launch ran on the server, which picked chat vs terminal from its own setting. The desktop relay now stamps its own default as nativeChatDefault; new servers use it in place of their setting (feasibility still decides), older servers strip it. Outside the replay fingerprint. Co-Authored-By: Claude * refactor(mobile): move the relay's delegated phone grant cache into its own module Keeps mobile-desktop-relay.ts under the line limit once #27749's page relay lands on it. Co-Authored-By: Claude * fix(mobile): show the early terminal tab from the relaying desktop's chat default The early-tab preflight read the server's own chat setting while the launch used the desktop's stamped nativeChatDefault, so on a windowed server with chats off a relayed chat launch flashed a terminal tab. Both now read the settings through readAgentLaunchModeSettings(runtime, nativeChatDefault). --------- Co-authored-by: Claude --- mobile/app/_layout.tsx | 10 +++-- .../notification-routing.test.ts | 14 ++++++ .../src/notifications/notification-routing.ts | 6 ++- .../notification-viewing-policy.ts | 8 ++-- mobile/src/notifications/push-payload.ts | 4 +- mobile/src/notifications/push-receive.test.ts | 44 +++++++++++++++++++ mobile/src/notifications/push-receive.ts | 3 +- .../notification-delivery-service.test.ts | 17 +++++++ .../notification-delivery-service.ts | 1 + src/main/runtime/push/push-dispatcher.ts | 3 +- src/main/runtime/push/push-gateway-client.ts | 1 + .../runtime-mobile-notification-controller.ts | 2 + .../structured-attention-dispatch.test.ts | 2 + .../structured-attention-dispatch.ts | 20 +++++---- .../use-notification-dispatch.test.ts | 23 ++++++++++ .../use-notification-dispatch.ts | 13 +++--- src/shared/notification-settings-types.ts | 2 + src/shared/notification-source.ts | 16 ++++++- 18 files changed, 164 insertions(+), 25 deletions(-) diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 711ee41ffd3..8b9a62c8391 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -45,15 +45,19 @@ Notifications.setNotificationHandler({ export default function RootLayout() { const router = useRouter() const pathname = usePathname() - const { hostId, worktreeId } = useGlobalSearchParams<{ hostId?: string; worktreeId?: string }>() + const { hostId, worktreeId, executionHost } = useGlobalSearchParams<{ + hostId?: string + worktreeId?: string + executionHost?: string + }>() useEffect(() => { setNotificationViewingWorkspace( pathname.includes('/session/') && typeof hostId === 'string' && typeof worktreeId === 'string' - ? { hostId, worktreeId } + ? { hostId, worktreeId, ...(typeof executionHost === 'string' ? { executionHost } : {}) } : null ) return () => setNotificationViewingWorkspace(null) - }, [pathname, hostId, worktreeId]) + }, [pathname, hostId, worktreeId, executionHost]) const openNotificationRoute = useOpenNotificationRoute() const handledNotificationIdsRef = useRef>(new Set()) diff --git a/mobile/src/notifications/notification-routing.test.ts b/mobile/src/notifications/notification-routing.test.ts index 9b682bc2cb7..b7d10b38db7 100644 --- a/mobile/src/notifications/notification-routing.test.ts +++ b/mobile/src/notifications/notification-routing.test.ts @@ -77,3 +77,17 @@ it('preserves the originating pane in the workspace route', () => { ?.sessionTarget?.params ).toEqual({ hostId: 'host', worktreeId: 'folder:/work', paneKey }) }) + +it('scopes a tap to a server workspace, and keeps anything else on the desktop', () => { + const paramsFor = (executionHost: unknown) => + getNotificationNavigationTarget({ hostId: 'host', worktreeId: 'repo::/srv', executionHost }) + ?.sessionTarget?.params + expect(paramsFor('runtime:env-1')).toEqual({ + hostId: 'host', + worktreeId: 'repo::/srv', + executionHost: 'runtime:env-1' + }) + for (const notAServer of [undefined, '', 'local', 'ssh:box', 'runtime:', 'env-1', 42]) { + expect(paramsFor(notAServer)).toEqual({ hostId: 'host', worktreeId: 'repo::/srv' }) + } +}) diff --git a/mobile/src/notifications/notification-routing.ts b/mobile/src/notifications/notification-routing.ts index d1a8b6eebf3..cbd6b4a1835 100644 --- a/mobile/src/notifications/notification-routing.ts +++ b/mobile/src/notifications/notification-routing.ts @@ -1,3 +1,4 @@ +import { parseExecutionHostId } from '../../../src/shared/execution-host' import type { HostStackRouteTarget } from '../navigation/host-stack-navigation' import { mobileSessionRouteTarget } from '../session/mobile-session-route' import type { HostCredentialStatus } from '../transport/types' @@ -46,6 +47,8 @@ export function getNotificationNavigationTarget( } const worktreeId = readNonEmptyString(record.worktreeId) + // Only a server the desktop relays to; anything else opens the desktop's own workspace, as before. + const executionHost = parseExecutionHostId(readNonEmptyString(record.executionHost)) const credentialStatus = options.credentialStatusByHostId?.get(hostId) return { hostId, @@ -53,7 +56,8 @@ export function getNotificationNavigationTarget( ? mobileSessionRouteTarget({ hostId, worktreeId, - paneKey: readNonEmptyString(record.paneKey) ?? undefined + paneKey: readNonEmptyString(record.paneKey) ?? undefined, + ...(executionHost?.kind === 'runtime' ? { executionHost: executionHost.id } : {}) }) : null, ...(credentialStatus === 'missing' diff --git a/mobile/src/notifications/notification-viewing-policy.ts b/mobile/src/notifications/notification-viewing-policy.ts index ea770cf3e74..3155810ee09 100644 --- a/mobile/src/notifications/notification-viewing-policy.ts +++ b/mobile/src/notifications/notification-viewing-policy.ts @@ -1,12 +1,13 @@ import { AppState } from 'react-native' -let viewing: { hostId: string; worktreeId: string } | null = null +// `executionHost` is the route's server; absent for the desktop's own workspaces. +let viewing: { hostId: string; worktreeId: string; executionHost?: string } | null = null export function setNotificationViewingWorkspace(value: typeof viewing): void { viewing = value } export function shouldSuppressNotificationWhileViewing( - event: { worktreeId?: string }, + event: { worktreeId?: string; executionHost?: string }, hostId: string, suppressWhileViewing: boolean ): boolean { @@ -14,6 +15,7 @@ export function shouldSuppressNotificationWhileViewing( suppressWhileViewing && AppState.currentState === 'active' && viewing?.hostId === hostId && - viewing.worktreeId === event.worktreeId + viewing.worktreeId === event.worktreeId && + viewing.executionHost === event.executionHost ) } diff --git a/mobile/src/notifications/push-payload.ts b/mobile/src/notifications/push-payload.ts index bcdbf0f6073..f100d987ce5 100644 --- a/mobile/src/notifications/push-payload.ts +++ b/mobile/src/notifications/push-payload.ts @@ -9,6 +9,7 @@ export type OrcaPushPayload = { readonly notificationEpoch?: string readonly paneKey?: string readonly worktreeId?: string + readonly executionHost?: string } function readString(value: unknown): string | undefined { @@ -38,6 +39,7 @@ export function readOrcaPushPayload(data: unknown): OrcaPushPayload | null { notificationSeq: readSeq(record.notificationSeq), notificationEpoch: readString(record.notificationEpoch), paneKey: readString(record.paneKey), - worktreeId: readString(record.worktreeId) + worktreeId: readString(record.worktreeId), + executionHost: readString(record.executionHost) } } diff --git a/mobile/src/notifications/push-receive.test.ts b/mobile/src/notifications/push-receive.test.ts index 0af8963d3b2..9128672ed79 100644 --- a/mobile/src/notifications/push-receive.test.ts +++ b/mobile/src/notifications/push-receive.test.ts @@ -177,6 +177,26 @@ describe('pushNotificationRouteData', () => { }) }) + it('opens a server workspace on its server, from either provider shape', () => { + for (const providerData of [apnsData, fcmData]) { + const data = pushNotificationRouteData( + providerData({ + hostFingerprint, + worktreeId: 'repo::/srv/feature', + executionHost: 'runtime:env-1', + source: 'agent-task-complete' + }), + hosts, + true + ) + expect(getNotificationNavigationTarget(data)?.sessionTarget?.params).toEqual({ + hostId: 'host-1', + worktreeId: 'repo::/srv/feature', + executionHost: 'runtime:env-1' + }) + } + }) + it('maps a push without a worktree to the host screen', () => { const data = pushNotificationRouteData( fcmData({ hostFingerprint, source: 'terminal-bell' }), @@ -297,3 +317,27 @@ it('allows the viewed workspace after backgrounding during eligibility reads', a await expect(eligibility).resolves.toBe(true) await expect(shouldSuppressForegroundPush(apnsData(payload))).resolves.toBe(false) }) + +it('silences only the viewed workspace on its own host, desktop or server', async () => { + AppState.currentState = 'active' + let seq = 0 + const present = (executionHost?: string) => + canPresentForegroundPush({ + hostFingerprint, + worktreeId: 'repo::/work', + ...(executionHost ? { executionHost } : {}), + notificationId: `viewing-${++seq}`, + notificationEpoch: 'epoch', + notificationSeq: seq + }) + setNotificationViewingWorkspace({ hostId: 'host-1', worktreeId: 'repo::/work' }) + await expect(present()).resolves.toBe(false) + await expect(present('runtime:env-1')).resolves.toBe(true) + setNotificationViewingWorkspace({ + hostId: 'host-1', + worktreeId: 'repo::/work', + executionHost: 'runtime:env-1' + }) + await expect(present('runtime:env-1')).resolves.toBe(false) + await expect(present()).resolves.toBe(true) +}) diff --git a/mobile/src/notifications/push-receive.ts b/mobile/src/notifications/push-receive.ts index b106ec555f2..92b4c376080 100644 --- a/mobile/src/notifications/push-receive.ts +++ b/mobile/src/notifications/push-receive.ts @@ -147,6 +147,7 @@ export function pushNotificationRouteData( return { hostId, ...(payload.paneKey ? { paneKey: payload.paneKey } : {}), - ...(payload.worktreeId ? { worktreeId: payload.worktreeId } : {}) + ...(payload.worktreeId ? { worktreeId: payload.worktreeId } : {}), + ...(payload.executionHost ? { executionHost: payload.executionHost } : {}) } } diff --git a/src/main/notifications/notification-delivery-service.test.ts b/src/main/notifications/notification-delivery-service.test.ts index 012ed0b771a..a96c10237c7 100644 --- a/src/main/notifications/notification-delivery-service.test.ts +++ b/src/main/notifications/notification-delivery-service.test.ts @@ -275,3 +275,20 @@ it('reports a muted host before a disabled source', () => { ) ).toEqual({ delivered: false, reason: 'host-muted' }) }) + +it("tells the phone which server runs the workspace, through the gateway's push", async () => { + for (const executionHost of [undefined, 'runtime:qa'] as const) { + const push = createPushHarness({ + devices: [{ deviceId: 'phone', pushRegistration: registration() }] + }) + const harness = makeHarness(makeSettings()) + harness.deps.dispatchMobileNotification = (event) => + push.dispatcher.enqueue({ ...event, notificationSeq: 1, notificationEpoch: 'epoch' }) + createNotificationDeliveryService(harness.deps).dispatch( + makeRequest({ agentState: 'done', ...(executionHost ? { executionHost } : {}) }) + ) + await flush() + expect(push.sends[0]?.notification.worktreeId).toBe('wt-1') + expect(push.sends[0]?.notification.executionHost).toBe(executionHost) + } +}) diff --git a/src/main/notifications/notification-delivery-service.ts b/src/main/notifications/notification-delivery-service.ts index c23b39907b1..3150006aaa6 100644 --- a/src/main/notifications/notification-delivery-service.ts +++ b/src/main/notifications/notification-delivery-service.ts @@ -120,6 +120,7 @@ export function createNotificationDeliveryService( title: notificationOptions.title, body: notificationOptions.body, worktreeId: request.worktreeId, + ...(request.executionHost ? { executionHost: request.executionHost } : {}), ...(request.notificationId ? { notificationId: request.notificationId } : {}), // Why: background push needs the agent's real state to pick "needs input" // vs "finished" — and to stay silent while the agent is still working. diff --git a/src/main/runtime/push/push-dispatcher.ts b/src/main/runtime/push/push-dispatcher.ts index fc29d09d485..2a95c11809e 100644 --- a/src/main/runtime/push/push-dispatcher.ts +++ b/src/main/runtime/push/push-dispatcher.ts @@ -189,7 +189,8 @@ export class PushDispatcher { agentState, title: clip(event.title, PUSH_TITLE_MAX_LENGTH), body: clip(event.body, PUSH_BODY_MAX_LENGTH), - ...(event.worktreeId ? { worktreeId: event.worktreeId } : {}) + ...(event.worktreeId ? { worktreeId: event.worktreeId } : {}), + ...(event.executionHost ? { executionHost: event.executionHost } : {}) } } } diff --git a/src/main/runtime/push/push-gateway-client.ts b/src/main/runtime/push/push-gateway-client.ts index 05fb9a1ffef..34376ae1fbd 100644 --- a/src/main/runtime/push/push-gateway-client.ts +++ b/src/main/runtime/push/push-gateway-client.ts @@ -48,6 +48,7 @@ export type PushSendNotification = { title: string body: string worktreeId?: string + executionHost?: `runtime:${string}` } type PushGatewayClientOptions = { diff --git a/src/main/runtime/runtime-mobile-notification-controller.ts b/src/main/runtime/runtime-mobile-notification-controller.ts index 00de771cc00..605b38a9f49 100644 --- a/src/main/runtime/runtime-mobile-notification-controller.ts +++ b/src/main/runtime/runtime-mobile-notification-controller.ts @@ -30,6 +30,8 @@ export type MobileNotificationDispatchEvent = { title: string body: string worktreeId?: string + /** See `NotificationDispatchRequest.executionHost`; old phones ignore it and open the desktop's. */ + executionHost?: `runtime:${string}` notificationId?: string notificationSeq?: number notificationEpoch?: string diff --git a/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts b/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts index 90ee22da44e..2a317070684 100644 --- a/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts +++ b/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts @@ -198,6 +198,8 @@ describe('dispatchStructuredTurnCompletionAttention', () => { environmentId: 'env-1' }) expect(dispatched.map((request) => request.mobileDeliveredByHost)).toEqual([true, undefined]) + // A phone opens the server's workspace on that server, and a local one on the desktop. + expect(dispatched.map((request) => request.executionHost)).toEqual([undefined, 'runtime:env-1']) expect(dispatched.map((request) => request.attentionKey)).toEqual([ agentSessionAttentionKey({ type: 'completion', completion: completion() }), agentSessionAttentionKey({ type: 'completion', completion: completion({ turnId: 'turn-2' }) }) diff --git a/src/renderer/src/components/native-chat/structured-attention-dispatch.ts b/src/renderer/src/components/native-chat/structured-attention-dispatch.ts index 4ccc39fba68..e5c25b488b7 100644 --- a/src/renderer/src/components/native-chat/structured-attention-dispatch.ts +++ b/src/renderer/src/components/native-chat/structured-attention-dispatch.ts @@ -25,7 +25,10 @@ * Prompt news already covered by an actual read is quiet, even when its attention frame arrives * later. Unread and delivery otherwise share the existing `resolveAgentAttention` decision. */ -import { notificationSourceForOwner } from '../../../../shared/notification-source' +import { + notificationExecutionHostForOwner, + notificationSourceForOwner +} from '../../../../shared/notification-source' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { resolveNotificationTabOwner } from '@/attention/notification-subject-owner' import type { @@ -135,6 +138,12 @@ function dispatchStructuredAttention( if (promptWasRead()) { return } + // The receiving subscription identifies the paired source even when tab ownership is ambiguous. + const owner = + subscriptionTarget?.kind === 'environment' + ? { executionHostId: null, runtimeEnvironmentId: subscriptionTarget.environmentId } + : resolveNotificationTabOwner(state, tab) + const executionHost = notificationExecutionHostForOwner(owner) deliverAgentAttentionNotification( { source: 'agent-task-complete', @@ -147,15 +156,10 @@ function dispatchStructuredAttention( // A local session's host is this app's own main, which already pushed its phones. ...(subscriptionTarget?.kind === 'local' ? { mobileDeliveredByHost: true } : {}), worktreeId: request.workspaceId, + ...(executionHost ? { executionHost } : {}), paneKey: request.subjectKey ?? undefined, ...getNotificationWorkspaceLabels(state, request.workspaceId, tab.label), - notificationSourceId: notificationSourceForOwner( - // The receiving subscription identifies the paired source even when tab ownership is ambiguous. - subscriptionTarget?.kind === 'environment' - ? { executionHostId: null, runtimeEnvironmentId: subscriptionTarget.environmentId } - : resolveNotificationTabOwner(state, tab), - state - ), + notificationSourceId: notificationSourceForOwner(owner, state), terminalTitle: tab.label, isActiveWorktree: request.workspaceIsActive, ...(row?.agentType ? { agentType: row.agentType } : {}), diff --git a/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts b/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts index 9956d34df4a..450bb470ce2 100644 --- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts @@ -241,6 +241,29 @@ describe('dispatchTerminalNotification', () => { expect(window.api.notifications.dispatch).toHaveBeenCalled() }) + it('names the server running the workspace so a phone opens it there, and none for local work', () => { + for (const workspaceOwner of [ + { executionHostId: 'local' as const, runtimeEnvironmentId: null }, + // A server's own SSH target still runs behind that server. + { executionHostId: 'ssh:qa' as const, runtimeEnvironmentId: 'env-1' }, + // A tab stamped with the server before its environment is known. + { executionHostId: 'runtime:env-2' as const, runtimeEnvironmentId: null } + ]) { + dispatchTerminalNotification('wt-primary', { + source: 'agent-task-complete', + terminalTitle: 'codex', + paneKey, + workspaceOwner + }) + } + const calls = vi.mocked(window.api.notifications.dispatch).mock.calls + expect(calls.map(([request]) => request.executionHost)).toEqual([ + undefined, + 'runtime:env-1', + 'runtime:env-2' + ]) + }) + it('writes unread with the completion reason while the agent-complete banner toggle is off', () => { // Why: the renderer never reads the desktop banner gate — main applies it after unread // and mobile delivery, so a disabled banner must still leave unread + tray attention. diff --git a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts index b4e2d205f5f..8e0b46de32e 100644 --- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts @@ -2,7 +2,10 @@ import { resolveTerminalNotificationOwner, type TerminalNotificationBinding } from '@/attention/notification-subject-owner' -import { notificationSourceForOwner } from '../../../../shared/notification-source' +import { + notificationExecutionHostForOwner, + notificationSourceForOwner +} from '../../../../shared/notification-source' import { useCallback } from 'react' import { useAppStore } from '@/store' import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' @@ -167,17 +170,17 @@ export function dispatchTerminalNotification( : null const requestDelivery = (request: AgentAttentionDeliveryRequest): void => { + const owner = resolveTerminalNotificationOwner(state, worktreeId, event) + const executionHost = notificationExecutionHostForOwner(owner) deliverAgentAttentionNotification( { source: event.source, ...(notificationId ? { notificationId } : {}), worktreeId: request.workspaceId, + ...(executionHost ? { executionHost } : {}), paneKey: request.subjectKey ?? undefined, ...getNotificationWorkspaceLabels(state, request.workspaceId, event.terminalTitle), - notificationSourceId: notificationSourceForOwner( - resolveTerminalNotificationOwner(state, worktreeId, event), - state - ), + notificationSourceId: notificationSourceForOwner(owner, state), terminalTitle: event.terminalTitle, isActiveWorktree: request.workspaceIsActive, ...agentSnapshot diff --git a/src/shared/notification-settings-types.ts b/src/shared/notification-settings-types.ts index ae0b38ae0bd..6956cbf8340 100644 --- a/src/shared/notification-settings-types.ts +++ b/src/shared/notification-settings-types.ts @@ -40,6 +40,8 @@ export type NotificationDispatchRequest = { worktreeId?: string /** Configured notification source; independent of physical execution location. */ notificationSourceId?: NotificationSourceId + /** The server the workspace runs on, so a paired phone opens it there; absent for this desktop's own work. */ + executionHost?: `runtime:${string}` /** Stable `${tabId}:${leafId}` terminal pane key for click-to-focus routing. */ paneKey?: string repoLabel?: string diff --git a/src/shared/notification-source.ts b/src/shared/notification-source.ts index a36e2aa90ac..dd6278d07e8 100644 --- a/src/shared/notification-source.ts +++ b/src/shared/notification-source.ts @@ -22,6 +22,19 @@ type NotificationSourceCatalog = { runtimeEnvironments?: readonly Pick[] } +function ownerRuntimeEnvironmentId(owner: NotificationWorkspaceOwner): string | null { + const host = parseExecutionHostId(owner.executionHostId) + return owner.runtimeEnvironmentId ?? (host?.kind === 'runtime' ? host.environmentId : null) +} + +/** The server a paired phone must open this work on; absent for work this desktop reaches itself. */ +export function notificationExecutionHostForOwner( + owner: NotificationWorkspaceOwner | null +): `runtime:${string}` | undefined { + const environmentId = owner ? ownerRuntimeEnvironmentId(owner) : null + return environmentId ? toRuntimeExecutionHostId(environmentId) : undefined +} + export function notificationSourceForOwner( owner: NotificationWorkspaceOwner | null, catalog: NotificationSourceCatalog @@ -30,8 +43,7 @@ export function notificationSourceForOwner( return undefined } const host = parseExecutionHostId(owner.executionHostId) - const environmentId = - owner.runtimeEnvironmentId ?? (host?.kind === 'runtime' ? host.environmentId : null) + const environmentId = ownerRuntimeEnvironmentId(owner) if (environmentId) { const environment = catalog.runtimeEnvironments?.find((entry) => entry.id === environmentId) if (!environment) {