refactor(mobile): match desktop project targets

This commit is contained in:
Brennan Benson
2026-08-13 23:42:57 -07:00
parent 7d751c3645
commit c0293ff82f
9 changed files with 402 additions and 190 deletions
+31 -13
View File
@@ -46,15 +46,26 @@ vi.mock('./TaskProviderLogo', () => ({ TaskProviderLogo: 'TaskProviderLogo' }))
import { setCachedRepos } from '../cache/repo-cache'
import { NewWorktreeModal } from './NewWorktreeModal'
const repos = [{ id: 'repo-1', displayName: 'orca', path: '/src/orca', kind: 'git' }]
const repos = [
{
id: 'repo-1',
displayName: 'orca',
path: '/src/orca',
kind: 'git',
upstream: { owner: 'stablyai', repo: 'orca' }
}
]
function repoPickerItems(renderer: ReactTestRenderer): { label: string; detail: string }[] {
function pickerItems(
renderer: ReactTestRenderer,
title: string
): { label: string; detail: string }[] {
const pickers = renderer.root.findAll((node) => node.type === 'PickerListDrawer')
const repoPicker = pickers.find((node) => node.props.title === 'Repository')
return repoPicker?.props.items ?? []
const picker = pickers.find((node) => node.props.title === title)
return picker?.props.items ?? []
}
describe('NewWorktreeModal repo list', () => {
describe('NewWorktreeModal project targets', () => {
let renderer: ReactTestRenderer
beforeEach(() => {
@@ -91,12 +102,15 @@ describe('NewWorktreeModal repo list', () => {
})
expect(sendRequest).toHaveBeenCalledWith('repo.list')
expect(repoPickerItems(renderer)).toEqual([
expect.objectContaining({ label: 'orca', detail: 'Local · /src/orca' })
expect(pickerItems(renderer, 'Project')).toEqual([
expect.objectContaining({ label: 'orca', detail: 'stablyai/orca' })
])
expect(pickerItems(renderer, 'Run on')).toEqual([
expect.objectContaining({ label: 'Local Mac', detail: '/src/orca' })
])
})
it('labels same-name local and SSH repositories with their locations', async () => {
it('groups same-name checkouts under one project with separate run targets', async () => {
const listedRepos = [
...repos,
{
@@ -104,7 +118,8 @@ describe('NewWorktreeModal repo list', () => {
displayName: 'orca',
path: '/home/dev/orca',
connectionId: 'build-server',
kind: 'git'
kind: 'git',
upstream: { owner: 'stablyai', repo: 'orca' }
}
]
const client = {
@@ -130,11 +145,14 @@ describe('NewWorktreeModal repo list', () => {
await Promise.resolve()
})
expect(repoPickerItems(renderer)).toEqual([
expect.objectContaining({ label: 'orca', detail: 'Local · /src/orca' }),
expect(pickerItems(renderer, 'Project')).toEqual([
expect.objectContaining({ label: 'orca', detail: 'stablyai/orca' })
])
expect(pickerItems(renderer, 'Run on')).toEqual([
expect.objectContaining({ label: 'Local Mac', detail: '/src/orca' }),
expect.objectContaining({
label: 'orca',
detail: 'SSH · build-server · /home/dev/orca'
label: 'SSH · build-server',
detail: '/home/dev/orca'
})
])
})
+65 -78
View File
@@ -10,7 +10,7 @@ import {
ActivityIndicator,
Keyboard
} from 'react-native'
import { ChevronDown, ChevronUp } from 'lucide-react-native'
import { ChevronDown, ChevronUp, Monitor } from 'lucide-react-native'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse, RpcSuccess } from '../transport/types'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
@@ -29,7 +29,11 @@ import {
type SetupHookTrust
} from '../tasks/setup-hook-trust'
import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents'
import type { PersistedTrustedOrcaHooks, TuiAgent } from '../../../src/shared/types'
import type {
PersistedTrustedOrcaHooks,
Repo as SharedRepo,
TuiAgent
} from '../../../src/shared/types'
import type { SshConnectionState } from '../../../src/shared/ssh-types'
import {
NEW_WORKTREE_AGENT_OPTIONS as AGENT_OPTIONS,
@@ -63,19 +67,27 @@ import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField'
import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer'
import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields'
import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer'
import { getNewWorkspaceRepoDetail } from './new-workspace-repo-detail'
import { NewWorktreeProjectTargetFields } from './NewWorktreeProjectTargetFields'
import {
buildNewWorkspaceProjectOptions,
buildNewWorkspaceRunTargetOptions,
getNewWorkspaceProjectId,
getNewWorkspaceRunTarget
} from './new-workspace-project-targets'
type Repo = {
id: string
displayName: string
path: string
badgeColor?: string
connectionId?: string | null
executionHostId?: 'local' | `ssh:${string}` | `runtime:${string}` | null
kind?: 'git' | 'folder'
upstream?: { owner: string; repo: string } | null
gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null
}
type Repo = Pick<SharedRepo, 'id' | 'displayName' | 'path'> &
Partial<
Pick<
SharedRepo,
| 'badgeColor'
| 'connectionId'
| 'executionHostId'
| 'kind'
| 'upstream'
| 'repoIcon'
| 'gitRemoteIdentity'
>
>
type SetupDecision = 'inherit' | 'run' | 'skip'
type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default'
@@ -696,16 +708,15 @@ function NewWorktreeModalContent({
isMobileTuiAgentEnabled(agent.id, runtimeSettings?.disabledTuiAgents)
)
const pickerAgentOptions = [...visibleAgentOptions, BLANK_TERMINAL]
const repoPickerItems = useMemo(
() =>
repos.map((repo) => ({
id: repo.id,
label: repo.displayName,
detail: getNewWorkspaceRepoDetail(repo),
repo
})),
[repos]
const projectPickerItems = useMemo(() => buildNewWorkspaceProjectOptions(repos), [repos])
const selectedProjectId = selectedRepo ? getNewWorkspaceProjectId(selectedRepo) : null
const selectedProject =
projectPickerItems.find((project) => project.id === selectedProjectId) ?? null
const runTargetPickerItems = useMemo(
() => buildNewWorkspaceRunTargetOptions(repos, selectedProjectId),
[repos, selectedProjectId]
)
const selectedRunTarget = selectedRepo ? getNewWorkspaceRunTarget(selectedRepo) : null
function prepareSelectionPickerOpen(): void {
// Why: picker taps can beat an open soft keyboard; dismissing it prevents the
@@ -793,10 +804,7 @@ function NewWorktreeModalContent({
>
<BottomDrawer visible={formSheetVisible} interactive={formSheetInteractive} onClose={onClose}>
<View style={styles.header}>
<Text style={styles.title}>Create Workspace</Text>
<Text style={styles.subtitle}>
Pick a repository and agent to spin up a new workspace.
</Text>
<Text style={styles.title}>Create worktree</Text>
</View>
{loading ? (
@@ -805,40 +813,23 @@ function NewWorktreeModalContent({
</View>
) : repos.length === 0 ? (
<View style={styles.loadingContainer}>
<Text style={styles.emptyText}>No repositories found</Text>
<Text style={styles.emptyText}>No projects found</Text>
</View>
) : (
<>
<View style={styles.field}>
<Text style={styles.label}>Repository</Text>
<Pressable
style={styles.fieldButton}
onPress={() => {
prepareSelectionPickerOpen()
transitionDrawer('repo')
}}
>
{selectedRepo ? (
<View
style={[styles.repoDot, { backgroundColor: repoBadgeColor(selectedRepo) }]}
/>
) : null}
<View style={styles.fieldButtonCopy}>
<Text
style={[styles.fieldButtonText, !selectedRepo && styles.fieldButtonPlaceholder]}
numberOfLines={1}
>
{selectedRepo?.displayName ?? 'Select a repository'}
</Text>
{selectedRepo ? (
<Text style={styles.fieldButtonDetail} numberOfLines={1}>
{getNewWorkspaceRepoDetail(selectedRepo)}
</Text>
) : null}
</View>
<ChevronDown size={14} color={colors.textMuted} />
</Pressable>
</View>
<NewWorktreeProjectTargetFields
project={selectedProject}
runTarget={selectedRunTarget}
projectBadgeColor={selectedRepo ? repoBadgeColor(selectedRepo) : null}
onOpenProject={() => {
prepareSelectionPickerOpen()
transitionDrawer('project')
}}
onOpenRunTarget={() => {
prepareSelectionPickerOpen()
transitionDrawer('runTarget')
}}
/>
<SmartWorkspaceSourceField
composer={composer}
@@ -908,7 +899,7 @@ function NewWorktreeModalContent({
>
<MobileAgentIcon agentId={selectedAgent.id} size={16} />
<Text style={styles.fieldButtonText} numberOfLines={1}>
{sshGate.requiresConnection ? 'Connect repository first' : selectedAgent.label}
{sshGate.requiresConnection ? 'Connect target first' : selectedAgent.label}
</Text>
<ChevronDown size={14} color={colors.textMuted} />
</Pressable>
@@ -1010,7 +1001,7 @@ function NewWorktreeModalContent({
<ActivityIndicator size="small" color={colors.bgBase} />
) : (
<Text style={styles.createText}>
{sshGate.requiresConnection ? 'Connect Repository' : 'Create Workspace'}
{sshGate.requiresConnection ? 'Connect target' : 'Create worktree'}
</Text>
)}
</Pressable>
@@ -1039,10 +1030,10 @@ function NewWorktreeModalContent({
/>
<PickerListDrawer
visible={visible && drawerView === 'repo'}
title="Repository"
items={repoPickerItems}
selectedId={selectedRepo?.id ?? ''}
visible={visible && drawerView === 'project'}
title="Project"
items={projectPickerItems}
selectedId={selectedProjectId ?? ''}
onSelect={(item) => handleRepoSelected(item.repo)}
onClose={() => transitionDrawer('form')}
renderIcon={(item) => {
@@ -1050,6 +1041,16 @@ function NewWorktreeModalContent({
}}
/>
<PickerListDrawer
visible={visible && drawerView === 'runTarget'}
title="Run on"
items={runTargetPickerItems}
selectedId={selectedRepo?.id ?? ''}
onSelect={(item) => handleRepoSelected(item.repo)}
onClose={() => transitionDrawer('form')}
renderIcon={() => <Monitor size={16} color={colors.textMuted} />}
/>
<PickerListDrawer
visible={visible && drawerView === 'agent'}
title="Agent"
@@ -1086,11 +1087,6 @@ const styles = StyleSheet.create({
fontWeight: '600',
color: colors.textPrimary
},
subtitle: {
fontSize: 13,
color: colors.textMuted,
marginTop: 2
},
loadingContainer: {
paddingVertical: spacing.xl,
alignItems: 'center'
@@ -1127,15 +1123,6 @@ const styles = StyleSheet.create({
fontSize: typography.bodySize,
color: colors.textPrimary
},
fieldButtonCopy: {
flex: 1,
minWidth: 0
},
fieldButtonDetail: {
fontSize: typography.metaSize,
color: colors.textMuted,
marginTop: 1
},
fieldButtonPlaceholder: {
color: colors.textMuted
},
@@ -0,0 +1,111 @@
import { View, Text, Pressable, StyleSheet, Platform } from 'react-native'
import { ChevronDown, Monitor } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
type Selection = { label: string; detail?: string }
export function NewWorktreeProjectTargetFields({
project,
runTarget,
projectBadgeColor,
onOpenProject,
onOpenRunTarget
}: {
project: Selection | null
runTarget: Selection | null
projectBadgeColor: string | null
onOpenProject: () => void
onOpenRunTarget: () => void
}) {
return (
<>
<View style={styles.field}>
<Text style={styles.label}>Project</Text>
<Pressable style={styles.fieldButton} onPress={onOpenProject}>
{projectBadgeColor ? (
<View style={[styles.projectDot, { backgroundColor: projectBadgeColor }]} />
) : null}
<SelectionCopy selection={project} placeholder="Select a project" />
<ChevronDown size={14} color={colors.textMuted} />
</Pressable>
</View>
<View style={styles.field}>
<Text style={styles.label}>Run on</Text>
<Pressable style={styles.fieldButton} onPress={onOpenRunTarget}>
<Monitor size={14} color={colors.textMuted} />
<SelectionCopy selection={runTarget} placeholder="Select a run target" />
<ChevronDown size={14} color={colors.textMuted} />
</Pressable>
</View>
</>
)
}
function SelectionCopy({
selection,
placeholder
}: {
selection: Selection | null
placeholder: string
}) {
return (
<View style={styles.fieldButtonCopy}>
<Text
style={[styles.fieldButtonText, !selection && styles.fieldButtonPlaceholder]}
numberOfLines={1}
>
{selection?.label ?? placeholder}
</Text>
{selection?.detail ? (
<Text style={styles.fieldButtonDetail} numberOfLines={1}>
{selection.detail}
</Text>
) : null}
</View>
)
}
const styles = StyleSheet.create({
field: {
marginBottom: spacing.md
},
label: {
fontSize: 13,
fontWeight: '500',
color: colors.textSecondary,
marginBottom: spacing.xs
},
fieldButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
borderWidth: 1,
borderColor: colors.borderSubtle,
paddingHorizontal: spacing.md,
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm
},
projectDot: {
width: 8,
height: 8,
borderRadius: 999
},
fieldButtonCopy: {
flex: 1,
minWidth: 0
},
fieldButtonText: {
fontSize: typography.bodySize,
color: colors.textPrimary
},
fieldButtonDetail: {
fontSize: typography.metaSize,
color: colors.textMuted,
marginTop: 1
},
fieldButtonPlaceholder: {
color: colors.textMuted
}
})
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest'
import {
buildNewWorkspaceProjectOptions,
buildNewWorkspaceRunTargetOptions,
getNewWorkspaceRunTarget
} from './new-workspace-project-targets'
describe('new workspace project targets', () => {
it('groups local and SSH checkouts of the same project', () => {
const upstream = { owner: 'stablyai', repo: 'orca' }
const options = buildNewWorkspaceProjectOptions([
{ id: 'local', displayName: 'orca', path: '/src/orca', upstream },
{
id: 'ssh',
displayName: 'orca',
path: '/home/dev/orca',
connectionId: 'build-server',
upstream
}
])
expect(options).toHaveLength(1)
expect(options[0]).toMatchObject({ label: 'orca', detail: 'stablyai/orca' })
})
it('shows the provider slug recovered from canonical git identity', () => {
const options = buildNewWorkspaceProjectOptions([
{
id: 'local',
displayName: 'orca',
path: '/src/orca',
gitRemoteIdentity: {
canonicalKey: 'github.com/stablyai/orca',
remoteName: 'origin',
remoteUrl: 'git@github.com:stablyai/orca.git'
}
}
])
expect(options[0]).toMatchObject({ label: 'orca', detail: 'stablyai/orca' })
})
it('labels local, SSH, and paired runtime targets', () => {
expect(
getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: '/src/orca' })
).toEqual({ label: 'Local Mac', detail: '/src/orca' })
expect(
getNewWorkspaceRunTarget({
id: 'ssh',
displayName: 'orca',
path: 'C:\\src\\orca',
executionHostId: 'ssh:Windows%20VM'
})
).toEqual({ label: 'SSH · Windows VM', detail: 'C:\\src\\orca' })
expect(
getNewWorkspaceRunTarget({
id: 'runtime',
displayName: 'orca',
path: '/src/orca',
executionHostId: 'runtime:devbox'
})
).toEqual({ label: 'Remote · devbox', detail: '/src/orca' })
})
it('shows one target per host when the project has multiple local worktrees', () => {
const upstream = { owner: 'stablyai', repo: 'orca' }
const repos = [
{ id: 'local-a', displayName: 'orca-a', path: '/src/orca-a', upstream },
{ id: 'local-b', displayName: 'orca-b', path: '/src/orca-b', upstream },
{
id: 'ssh',
displayName: 'orca',
path: '/home/dev/orca',
connectionId: 'build-server',
upstream
}
]
const projectId = buildNewWorkspaceProjectOptions(repos)[0]?.id ?? null
expect(buildNewWorkspaceRunTargetOptions(repos, projectId)).toEqual([
expect.objectContaining({ id: 'local-a', label: 'Local Mac', detail: '/src/orca-a' }),
expect.objectContaining({ id: 'ssh', label: 'SSH · build-server', detail: '/home/dev/orca' })
])
})
})
@@ -0,0 +1,101 @@
import type { Repo } from '../../../src/shared/types'
import { getRepoExecutionHostId } from '../../../src/shared/execution-host'
import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection'
type WorkspaceRepo = Pick<Repo, 'id' | 'displayName' | 'path'> &
Partial<
Pick<Repo, 'connectionId' | 'executionHostId' | 'upstream' | 'repoIcon' | 'gitRemoteIdentity'>
>
export type NewWorkspaceProjectOption<TRepo extends WorkspaceRepo> = {
id: string
label: string
detail?: string
repo: TRepo
}
export type NewWorkspaceRunTargetOption<TRepo extends WorkspaceRepo> = {
id: string
label: string
detail: string
repo: TRepo
}
function executionHostName(executionHostId: string): string {
const encodedName = executionHostId.slice(executionHostId.indexOf(':') + 1)
try {
return decodeURIComponent(encodedName)
} catch {
return encodedName
}
}
export function getNewWorkspaceProjectId(repo: WorkspaceRepo): string {
return getProjectIdentityKey(repo)
}
export function buildNewWorkspaceProjectOptions<TRepo extends WorkspaceRepo>(
repos: readonly TRepo[]
): NewWorkspaceProjectOption<TRepo>[] {
const options = new Map<string, NewWorkspaceProjectOption<TRepo>>()
const hostIdsByProject = new Map<string, Set<string>>()
for (const repo of repos) {
const id = getNewWorkspaceProjectId(repo)
const hostIds = hostIdsByProject.get(id) ?? new Set<string>()
hostIds.add(getRepoExecutionHostId(repo))
hostIdsByProject.set(id, hostIds)
if (!options.has(id)) {
options.set(id, {
id,
label: repo.displayName,
repo
})
}
}
return [...options.values()].map((option) => {
const providerParts = option.id.startsWith('github:')
? option.id.slice('github:'.length).split('/')
: []
const providerDetail = providerParts.slice(-2).join('/')
const hostCount = hostIdsByProject.get(option.id)?.size ?? 0
const detail = providerDetail || (hostCount > 1 ? `${hostCount} hosts configured` : '')
return detail ? { ...option, detail } : option
})
}
export function getNewWorkspaceRunTarget(repo: WorkspaceRepo): {
label: string
detail: string
} {
const connectionId = repo.connectionId?.trim()
if (connectionId) {
return { label: `SSH · ${connectionId}`, detail: repo.path }
}
if (repo.executionHostId?.startsWith('ssh:')) {
return { label: `SSH · ${executionHostName(repo.executionHostId)}`, detail: repo.path }
}
if (repo.executionHostId?.startsWith('runtime:')) {
return { label: `Remote · ${executionHostName(repo.executionHostId)}`, detail: repo.path }
}
return { label: 'Local Mac', detail: repo.path }
}
export function buildNewWorkspaceRunTargetOptions<TRepo extends WorkspaceRepo>(
repos: readonly TRepo[],
projectId: string | null
): NewWorkspaceRunTargetOption<TRepo>[] {
if (!projectId) {
return []
}
const options = new Map<string, NewWorkspaceRunTargetOption<TRepo>>()
for (const repo of repos) {
if (getNewWorkspaceProjectId(repo) !== projectId) {
continue
}
const hostId = getRepoExecutionHostId(repo)
if (!options.has(hostId)) {
options.set(hostId, { id: repo.id, ...getNewWorkspaceRunTarget(repo), repo })
}
}
return [...options.values()]
}
@@ -1,56 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getNewWorkspaceRepoDetail } from './new-workspace-repo-detail'
describe('new workspace repository detail', () => {
it('identifies local repositories', () => {
expect(getNewWorkspaceRepoDetail({ path: '/src/orca' })).toBe('Local · /src/orca')
})
it('identifies SSH repositories by connection and path', () => {
expect(getNewWorkspaceRepoDetail({ path: '/src/orca', connectionId: 'build-server' })).toBe(
'SSH · build-server · /src/orca'
)
})
it('uses the execution host when an SSH connection ID is absent', () => {
expect(
getNewWorkspaceRepoDetail({ path: 'C:\\src\\orca', executionHostId: 'ssh:Windows%20VM' })
).toBe('SSH · Windows VM · C:\\src\\orca')
})
it('distinguishes paired runtime repositories from local repositories', () => {
expect(
getNewWorkspaceRepoDetail({ path: '/src/orca', executionHostId: 'runtime:devbox' })
).toBe('Remote · devbox · /src/orca')
})
it('prefers the explicit execution host over stale SSH metadata', () => {
expect(
getNewWorkspaceRepoDetail({
path: '/src/orca',
connectionId: 'old-ssh-target',
executionHostId: 'runtime:devbox'
})
).toBe('Remote · devbox · /src/orca')
expect(
getNewWorkspaceRepoDetail({
path: '/src/orca',
connectionId: 'old-ssh-target',
executionHostId: 'local'
})
).toBe('Local · /src/orca')
})
it('falls back when an execution host label is malformed', () => {
expect(
getNewWorkspaceRepoDetail({
path: '/src/orca',
connectionId: 'build-server',
executionHostId: 'ssh:%E0%A4%A'
})
).toBe('SSH · build-server · /src/orca')
expect(getNewWorkspaceRepoDetail({ path: '/src/orca', executionHostId: 'runtime:' })).toBe(
'Local · /src/orca'
)
})
})
@@ -1,41 +0,0 @@
type RepoLocation = {
path: string
connectionId?: string | null
executionHostId?: 'local' | `ssh:${string}` | `runtime:${string}` | null
}
function executionHostName(executionHostId: string): string | null {
const encodedName = executionHostId.slice(executionHostId.indexOf(':') + 1)
if (!encodedName) {
return null
}
try {
return decodeURIComponent(encodedName) || null
} catch {
return null
}
}
export function getNewWorkspaceRepoDetail(repo: RepoLocation): string {
const executionHostId = repo.executionHostId?.trim()
if (executionHostId === 'local') {
return `Local · ${repo.path}`
}
if (executionHostId?.startsWith('ssh:')) {
const hostName = executionHostName(executionHostId)
if (hostName) {
return `SSH · ${hostName} · ${repo.path}`
}
}
if (executionHostId?.startsWith('runtime:')) {
const hostName = executionHostName(executionHostId)
if (hostName) {
return `Remote · ${hostName} · ${repo.path}`
}
}
const connectionId = repo.connectionId?.trim()
if (connectionId) {
return `SSH · ${connectionId} · ${repo.path}`
}
return `Local · ${repo.path}`
}
@@ -30,7 +30,7 @@ describe('resolveNewWorktreeFormSheetVisible', () => {
expect(
resolveNewWorktreeFormSheetVisible({
modalVisible: true,
drawerView: 'repo',
drawerView: 'project',
formPinnedUnderSource: false
})
).toBe(false)
@@ -2,7 +2,14 @@ import { useEffect, useRef, useState } from 'react'
import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants'
import { resolveNewWorktreeFormSheetVisible } from './new-worktree-form-sheet-visibility'
export type NewWorktreeDrawerView = 'form' | 'transition' | 'source' | 'repo' | 'agent' | 'trust'
export type NewWorktreeDrawerView =
| 'form'
| 'transition'
| 'source'
| 'project'
| 'runTarget'
| 'agent'
| 'trust'
// Why: iOS cannot reliably present a second native modal until the first drawer's
// exit commits; one extra frame keeps transitions sequential on slower devices.