Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions config/scripts/run-multi-client-navigation-e2e.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { spawnSync } from 'node:child_process'

const rawExtraArgs = process.argv.slice(2)
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const env = {
...process.env,
ORCA_E2E_WEB_CLIENT: '1'
}

// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
const spawnOptions = {
stdio: 'inherit',
env,
shell: process.platform === 'win32'
}

const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)

if (runtime.status !== 0) {
process.exit(runtime.status ?? 1)
}

const result = spawnSync(
pnpm,
[
'exec',
'playwright',
'test',
'tests/e2e/multi-client-navigation-isolation.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--workers=1',
...extraArgs
],
spawnOptions
)

process.exit(result.status ?? 1)
7 changes: 3 additions & 4 deletions mobile/app/h/[hostId]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -632,8 +632,7 @@ export function HostScreen({
router.push(target)
return
}
const targetPath = target.split('?')[0] ?? target
if (pathname === targetPath) {
if (pathname === (target.split('?')[0] ?? target)) {
return
}
if (pathname === `/h/${hostId}`) {
Expand All @@ -649,11 +648,11 @@ export function HostScreen({
(item: Worktree) => {
setOptimisticActiveWorktreeId(item.worktreeId)
if (client && connState === 'connected') {
// Why: notifyClients:false hydrates host tabs without pulling desktop into this worktree.
void client
.sendRequest('worktree.activate', {
worktree: `id:${item.worktreeId}`,
notifyClients: false
notifyClients: false,
navigation: 'caller'
})
.catch(() => null)
}
Expand Down
32 changes: 18 additions & 14 deletions mobile/app/h/[hostId]/session/[worktreeId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,7 @@ import { getMobileTerminalActionSheetActions } from '../../../../src/session/mob
import * as nativeChatTerminalStream from '../../../../src/session/mobile-native-chat-terminal-stream'
import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream'
import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe'
import {
activateMobileSessionTab,
focusMobileTerminal
} from '../../../../src/session/mobile-session-tab-activation'
import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation'
import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics'
import {
getRepoIdFromMobileWorktreeId,
Expand Down Expand Up @@ -2630,7 +2627,8 @@ export default function SessionScreen() {
void client
.sendRequest('worktree.activate', {
worktree: `id:${worktreeId}`,
notifyClients: false
notifyClients: false,
navigation: 'caller'
})
.then((response) => reportActivationOutcome(response.ok ? response : null))
.catch(() => null)
Expand All @@ -2657,7 +2655,8 @@ export default function SessionScreen() {
const activationResponse = await client
.sendRequest('worktree.activate', {
worktree: `id:${worktreeId}`,
notifyClients: false
notifyClients: false,
navigation: 'caller'
})
.catch(() => null)
reportActivationOutcome(activationResponse?.ok ? activationResponse : null)
Expand Down Expand Up @@ -2780,7 +2779,7 @@ export default function SessionScreen() {
}, [])
)

// Why: unsubscribe restores old dims (clears phone-fit banner); resubscribe phone-fits the new one; terminal.focus makes desktop follow.
// Why: unsubscribe restores old dims (clears phone-fit banner); resubscribe phone-fits the new one.
const switchTab = useCallback(
(handle: string) => {
triggerSelection()
Expand Down Expand Up @@ -2808,13 +2807,12 @@ export default function SessionScreen() {
}
subscribeToTerminal(handle)
if (client) {
void focusMobileTerminal(client, handle).catch(() => {})
if (matchingTab) {
// Why: persist selection for headless hosts; snapshot gate stops this phone-local ack from impersonating desktop focus.
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: matchingTab.id,
notifyClients: false
notifyClients: false,
navigation: 'caller'
}).catch(() => {})
}
}
Expand Down Expand Up @@ -2853,7 +2851,8 @@ export default function SessionScreen() {
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false
notifyClients: false,
navigation: 'caller'
}).catch(() => {})
}
return
Expand All @@ -2876,7 +2875,8 @@ export default function SessionScreen() {
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false
notifyClients: false,
navigation: 'caller'
}).catch(() => {})
}
if (tab.type === 'browser') {
Expand Down Expand Up @@ -3750,7 +3750,10 @@ export default function SessionScreen() {
? { startupCommandDelivery: options.startupCommandDelivery }
: {}),
...(options?.agentPrompt ? { agentPrompt: options.agentPrompt } : {}),
...(agent ? { agent } : {})
...(agent ? { agent } : {}),
activate: false,
select: true,
navigation: 'caller'
})
if (response.ok) {
const result = (response as RpcSuccess).result as TerminalCreateResult
Expand Down Expand Up @@ -4145,7 +4148,8 @@ export default function SessionScreen() {
worktree: `id:${worktreeId}`,
tabId: activePendingTerminalTab.id,
leafId: activePendingTerminalTab.leafId,
notifyClients: false
notifyClients: false,
navigation: 'caller'
})
.then((response) => {
if (!response.ok) {
Expand Down
12 changes: 12 additions & 0 deletions mobile/app/notification-opt-in.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Redirect, useLocalSearchParams } from 'expo-router'
import {
legacyNotificationOptInDestination,
type LegacyNotificationOptInParams
} from '../src/onboarding/legacy-notification-opt-in-destination'

export default function NotificationOptInRedirect() {
const params = useLocalSearchParams<LegacyNotificationOptInParams>()

// Why: restored stacks may contain this retired route; replacement keeps Back from reopening it.
return <Redirect href={legacyNotificationOptInDestination(params)} />
}
19 changes: 19 additions & 0 deletions mobile/src/onboarding/legacy-notification-opt-in-destination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export type LegacyNotificationOptInParams = {
hostId?: string | string[]
}

export type LegacyNotificationOptInDestination = {
pathname: '/mobile-onboarding'
params: { steps: 'notifications'; hostId?: string }
}

/** Maps restored navigation and old deep links onto the canonical notifications step. */
export function legacyNotificationOptInDestination(
params: LegacyNotificationOptInParams
): LegacyNotificationOptInDestination {
const hostId = Array.isArray(params.hostId) ? params.hostId[0] : params.hostId
return {
pathname: '/mobile-onboarding',
params: { steps: 'notifications', ...(hostId ? { hostId } : {}) }
}
}
74 changes: 74 additions & 0 deletions mobile/src/onboarding/legacy-notification-opt-in-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from 'vitest'
import NotificationOptInRedirect from '../../app/notification-opt-in'
import { legacyNotificationOptInDestination } from './legacy-notification-opt-in-destination'

const mocks = vi.hoisted(() => ({
params: {} as Record<string, string | string[] | undefined>,
ensureNotificationPermissions: vi.fn()
}))

vi.mock('expo-router', () => ({
Redirect: 'Redirect',
useLocalSearchParams: () => mocks.params
}))

vi.mock('../notifications/mobile-notifications', () => ({
ensureNotificationPermissions: mocks.ensureNotificationPermissions
}))

describe('legacy notification opt-in destination', () => {
it('selects only notifications and preserves a scalar hostId', () => {
expect(legacyNotificationOptInDestination({ hostId: 'paired-host' })).toEqual({
pathname: '/mobile-onboarding',
params: { steps: 'notifications', hostId: 'paired-host' }
})
})

it.each([
[['first-host', 'second-host'], 'first-host'],
[[], undefined],
[undefined, undefined]
] as const)('normalizes hostId %j to %s', (hostId, expectedHostId) => {
expect(legacyNotificationOptInDestination({ hostId })).toEqual({
pathname: '/mobile-onboarding',
params: {
steps: 'notifications',
...(expectedHostId ? { hostId: expectedHostId } : {})
}
})
})

it('ignores unrelated legacy parameters', () => {
const legacyParams = {
hostId: 'paired-host',
steps: 'session-view',
returnTo: '/settings'
}

expect(legacyNotificationOptInDestination(legacyParams)).toEqual({
pathname: '/mobile-onboarding',
params: { steps: 'notifications', hostId: 'paired-host' }
})
})
})

describe('/notification-opt-in compatibility route', () => {
it('is present and synchronously returns a replace-style redirect to the canonical route', () => {
mocks.params = {
hostId: ['paired-host', 'ignored-host'],
steps: 'session-view',
unrelated: 'ignored'
}

expect(NotificationOptInRedirect()).toMatchObject({
type: 'Redirect',
props: {
href: {
pathname: '/mobile-onboarding',
params: { steps: 'notifications', hostId: 'paired-host' }
}
}
})
expect(mocks.ensureNotificationPermissions).not.toHaveBeenCalled()
})
})
10 changes: 8 additions & 2 deletions mobile/src/session/ai-vault-resume-launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,10 @@ describe('resumeAiVaultSessionInTerminal', () => {
agentEnv: { ANTHROPIC_BASE_URL: 'http://localhost:3000' }
},
launchAgent: 'claude',
clientMutationId: 'resume-1'
clientMutationId: 'resume-1',
activate: false,
select: true,
navigation: 'caller'
})
).resolves.toMatchObject({ id: 'tab-1', terminal: 'pty-1' })
expect(sendRequest).toHaveBeenNthCalledWith(
Expand All @@ -268,7 +271,10 @@ describe('resumeAiVaultSessionInTerminal', () => {
agentEnv: { ANTHROPIC_BASE_URL: 'http://localhost:3000' }
},
launchAgent: 'claude',
clientMutationId: 'resume-1'
clientMutationId: 'resume-1',
activate: false,
select: true,
navigation: 'caller'
},
// Why: a socket drop mid-resume must reject within the request timeout
// instead of parking on the reconnect waiter with the spinner pinned.
Expand Down
5 changes: 4 additions & 1 deletion mobile/src/session/ai-vault-resume-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,10 @@ export async function resumeAiVaultSessionInTerminal(
...(launch.envToDelete ? { envToDelete: launch.envToDelete } : {}),
...(launch.launchConfig ? { launchConfig: launch.launchConfig } : {}),
...(launch.launchAgent ? { launchAgent: launch.launchAgent } : {}),
...(launch.clientMutationId ? { clientMutationId: launch.clientMutationId } : {})
...(launch.clientMutationId ? { clientMutationId: launch.clientMutationId } : {}),
activate: false,
select: true,
navigation: 'caller'
},
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
)
Expand Down
Loading
Loading