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) {